Compare commits

...
8 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 7cc16161b3 Camera connection quality indicator (#21297)
* add camera connection quality metrics and indicator

* formatting

* move stall calcs to watchdog

* clean up

* change watchdog to 1s and separately track time for ffmpeg retry_interval

* implement status caching to reduce message volume
2025-12-15 14:02:03 -07:00
Nicolas MowenandGitHub 08311a6ee2 Case management UI (#21299)
* Refactor export cards to match existing cards in other UI pages

* Show cases separately from exports

* Add proper filtering and display of cases

* Add ability to edit and select cases for exports

* Cleanup typing

* Hide if no unassigned

* Cleanup hiding logic

* fix scrolling

* Improve layout
2025-12-15 13:10:50 -07:00
Josh HawkinsandGitHub a08c044144 refactor vainfo to search for first GPU (#21296)
use existing LibvaGpuSelector to pick appropritate libva device
2025-12-15 08:58:50 -07:00
Nicolas MowenandGitHub 5cced22f65 implement case management for export apis (#21295) 2025-12-15 08:54:13 -07:00
Nicolas MowenandGitHub b962c95725 Create scaffolding for case management (#21293) 2025-12-15 08:28:52 -07:00
Nicolas Mowen 0cbec25494 Update version 2025-12-15 07:46:31 -07:00
e1545a8db8 Miscellaneous Fixes (0.17 beta) (#21279)
CI / Synaptics Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
* Fix Safari popover issue in classification wizard

* use name for key instead of title

prevents duplicate key warnings when users mix vaapi and qsv

* update auth api endpoint descriptions and docs

* tweak headings

* fix note

* clarify classification docs

* Fix cuda birdseye

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2025-12-14 16:41:38 -07:00
Josh HawkinsandGitHub 51ee6f26e6 Fix yolov9 coral docs labelmap path (#21278) 2025-12-14 11:00:48 -07:00
32 changed files with 1555 additions and 226 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.17.0
VERSION = 0.18.0
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty
+39
View File
@@ -270,3 +270,42 @@ To use role-based access control, you must connect to Frigate via the **authenti
1. Log in as an **admin** user via port `8971`.
2. Navigate to **Settings > Users**.
3. Edit a users role by selecting **admin** or **viewer**.
## API Authentication Guide
### Getting a Bearer Token
To use the Frigate API, you need to authenticate first. Follow these steps to obtain a Bearer token:
#### 1. Login
Make a POST request to `/login` with your credentials:
```bash
curl -i -X POST https://frigate_ip:8971/api/login \
-H "Content-Type: application/json" \
-d '{"user": "admin", "password": "your_password"}'
```
:::note
You may need to include `-k` in the argument list in these steps (eg: `curl -k -i -X POST ...`) if your Frigate instance is using a self-signed certificate.
:::
The response will contain a cookie with the JWT token.
#### 2. Using the Bearer Token
Once you have the token, include it in the Authorization header for subsequent requests:
```bash
curl -H "Authorization: Bearer <your_token>" https://frigate_ip:8971/api/profile
```
#### 3. Token Lifecycle
- Tokens are valid for the configured session length
- Tokens are automatically refreshed when you visit the `/auth` endpoint
- Tokens are invalidated when the user's password is changed
- Use `/logout` to clear your session cookie
@@ -60,11 +60,9 @@ Choose one or more cameras and draw a rectangle over the area of interest for ea
### Step 3: Assign Training Examples
The system will automatically generate example images from your camera feeds. You'll be guided through each class one at a time to select which images represent that state.
The system will automatically generate example images from your camera feeds. You'll be guided through each class one at a time to select which images represent that state. It's not strictly required to select all images you see. If a state is missing from the samples, you can train it from the Recent tab later.
**Important**: All images must be assigned to a state before training can begin. This includes images that may not be optimal, such as when people temporarily block the view, sun glare is present, or other distractions occur. Assign these images to the state that is actually present (based on what you know the state to be), not based on the distraction. This training helps the model correctly identify the state even when such conditions occur during inference.
Once all images are assigned, training will begin automatically.
Once some images are assigned, training will begin automatically.
### Improving the Model
+1 -1
View File
@@ -175,7 +175,7 @@ model:
width: 320 # <--- should match the imgsize of the model, typically 320
height: 320 # <--- should match the imgsize of the model, typically 320
path: /config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite
labelmap_path: /labelmap/labels-coco-17.txt
labelmap_path: /config/labels-coco17.txt
```
Note that the labelmap uses a subset of the complete COCO label set that has only 17 objects.
+96 -9
View File
@@ -14,7 +14,11 @@ paths:
get:
tags:
- Auth
summary: Auth
summary: Authenticate request
description: |-
Authenticates the current request based on proxy headers or JWT token.
Returns user role and permissions for camera access.
This endpoint verifies authentication credentials and manages JWT token refresh.
operationId: auth_auth_get
responses:
"200":
@@ -22,11 +26,21 @@ paths:
content:
application/json:
schema: {}
"202":
description: Authentication Accepted
content:
application/json:
schema: {}
"401":
description: Authentication Failed
/profile:
get:
tags:
- Auth
summary: Profile
summary: Get user profile
description: |-
Returns the current authenticated user's profile including username, role, and allowed cameras.
This endpoint requires authentication and returns information about the user's permissions.
operationId: profile_profile_get
responses:
"200":
@@ -34,11 +48,16 @@ paths:
content:
application/json:
schema: {}
"401":
description: Unauthorized
/logout:
get:
tags:
- Auth
summary: Logout
summary: Logout user
description: |-
Logs out the current user by clearing the session cookie.
After logout, subsequent requests will require re-authentication.
operationId: logout_logout_get
responses:
"200":
@@ -46,11 +65,22 @@ paths:
content:
application/json:
schema: {}
"303":
description: See Other (redirects to login page)
/login:
post:
tags:
- Auth
summary: Login
summary: Login with credentials
description: |-
Authenticates a user with username and password.
Returns a JWT token as a secure HTTP-only cookie that can be used for subsequent API requests.
The JWT token can also be retrieved from the response and used as a Bearer token in the Authorization header.
Example using Bearer token:
```
curl -H "Authorization: Bearer <token_value>" https://frigate_ip:8971/api/profile
```
operationId: login_login_post
requestBody:
required: true
@@ -64,6 +94,11 @@ paths:
content:
application/json:
schema: {}
"401":
description: Login Failed - Invalid credentials
content:
application/json:
schema: {}
"422":
description: Validation Error
content:
@@ -74,7 +109,10 @@ paths:
get:
tags:
- Auth
summary: Get Users
summary: Get all users
description: |-
Returns a list of all users with their usernames and roles.
Requires admin role. Each user object contains the username and assigned role.
operationId: get_users_users_get
responses:
"200":
@@ -82,10 +120,19 @@ paths:
content:
application/json:
schema: {}
"403":
description: Forbidden - Admin role required
post:
tags:
- Auth
summary: Create User
summary: Create new user
description: |-
Creates a new user with the specified username, password, and role.
Requires admin role. Password must meet strength requirements:
- Minimum 8 characters
- At least one uppercase letter
- At least one digit
- At least one special character (!@#$%^&*(),.?":{}\|<>)
operationId: create_user_users_post
requestBody:
required: true
@@ -99,6 +146,13 @@ paths:
content:
application/json:
schema: {}
"400":
description: Bad Request - Invalid username or role
content:
application/json:
schema: {}
"403":
description: Forbidden - Admin role required
"422":
description: Validation Error
content:
@@ -109,7 +163,10 @@ paths:
delete:
tags:
- Auth
summary: Delete User
summary: Delete user
description: |-
Deletes a user by username. The built-in admin user cannot be deleted.
Requires admin role. Returns success message or error if user not found.
operationId: delete_user_users__username__delete
parameters:
- name: username
@@ -118,12 +175,15 @@ paths:
schema:
type: string
title: Username
description: The username of the user to delete
responses:
"200":
description: Successful Response
content:
application/json:
schema: {}
"403":
description: Forbidden - Cannot delete admin user or admin role required
"422":
description: Validation Error
content:
@@ -134,7 +194,17 @@ paths:
put:
tags:
- Auth
summary: Update Password
summary: Update user password
description: |-
Updates a user's password. Users can only change their own password unless they have admin role.
Requires the current password to verify identity for non-admin users.
Password must meet strength requirements:
- Minimum 8 characters
- At least one uppercase letter
- At least one digit
- At least one special character (!@#$%^&*(),.?":{}\|<>)
If user changes their own password, a new JWT cookie is automatically issued.
operationId: update_password_users__username__password_put
parameters:
- name: username
@@ -143,6 +213,7 @@ paths:
schema:
type: string
title: Username
description: The username of the user whose password to update
requestBody:
required: true
content:
@@ -155,6 +226,14 @@ paths:
content:
application/json:
schema: {}
"400":
description: Bad Request - Current password required or password doesn't meet requirements
"401":
description: Unauthorized - Current password is incorrect
"403":
description: Forbidden - Viewers can only update their own password
"404":
description: Not Found - User not found
"422":
description: Validation Error
content:
@@ -165,7 +244,10 @@ paths:
put:
tags:
- Auth
summary: Update Role
summary: Update user role
description: |-
Updates a user's role. The built-in admin user's role cannot be modified.
Requires admin role. Valid roles are defined in the configuration.
operationId: update_role_users__username__role_put
parameters:
- name: username
@@ -174,6 +256,7 @@ paths:
schema:
type: string
title: Username
description: The username of the user whose role to update
requestBody:
required: true
content:
@@ -186,6 +269,10 @@ paths:
content:
application/json:
schema: {}
"400":
description: Bad Request - Invalid role
"403":
description: Forbidden - Cannot modify admin user's role or admin role required
"422":
description: Validation Error
content:
+10 -1
View File
@@ -32,6 +32,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.models import Event, Timeline
from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.util.builtin import (
@@ -458,7 +459,15 @@ def config_set(request: Request, body: AppConfigSetBody):
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
def vainfo():
vainfo = vainfo_hwaccel()
# Use LibvaGpuSelector to pick an appropriate libva device (if available)
selected_gpu = ""
try:
selected_gpu = _gpu_selector.get_gpu_arg(FFMPEG_HWACCEL_VAAPI, 0) or ""
except Exception:
selected_gpu = ""
# If selected_gpu is empty, pass None to vainfo_hwaccel to run plain `vainfo`.
vainfo = vainfo_hwaccel(device_name=selected_gpu or None)
return JSONResponse(
content={
"return_code": vainfo.returncode,
+48 -8
View File
@@ -549,7 +549,12 @@ def resolve_role(
# Endpoints
@router.get("/auth", dependencies=[Depends(allow_public())])
@router.get(
"/auth",
dependencies=[Depends(allow_public())],
summary="Authenticate request",
description="Authenticates the current request based on proxy headers or JWT token. Returns user role and permissions for camera access.",
)
def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
proxy_config: ProxyConfig = request.app.frigate_config.proxy
@@ -689,7 +694,12 @@ def auth(request: Request):
return fail_response
@router.get("/profile", dependencies=[Depends(allow_any_authenticated())])
@router.get(
"/profile",
dependencies=[Depends(allow_any_authenticated())],
summary="Get user profile",
description="Returns the current authenticated user's profile including username, role, and allowed cameras.",
)
def profile(request: Request):
username = request.headers.get("remote-user", "anonymous")
role = request.headers.get("remote-role", "viewer")
@@ -703,7 +713,12 @@ def profile(request: Request):
)
@router.get("/logout", dependencies=[Depends(allow_public())])
@router.get(
"/logout",
dependencies=[Depends(allow_public())],
summary="Logout user",
description="Logs out the current user by clearing the session cookie.",
)
def logout(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
response = RedirectResponse("/login", status_code=303)
@@ -714,7 +729,12 @@ def logout(request: Request):
limiter = Limiter(key_func=get_remote_addr)
@router.post("/login", dependencies=[Depends(allow_public())])
@router.post(
"/login",
dependencies=[Depends(allow_public())],
summary="Login with credentials",
description="Authenticates a user with username and password. Returns a JWT token as a secure HTTP-only cookie that can be used for subsequent API requests. The token can also be retrieved and used as a Bearer token in the Authorization header.",
)
@limiter.limit(limit_value=rateLimiter.get_limit)
def login(request: Request, body: AppPostLoginBody):
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
@@ -752,7 +772,12 @@ def login(request: Request, body: AppPostLoginBody):
return JSONResponse(content={"message": "Login failed"}, status_code=401)
@router.get("/users", dependencies=[Depends(require_role(["admin"]))])
@router.get(
"/users",
dependencies=[Depends(require_role(["admin"]))],
summary="Get all users",
description="Returns a list of all users with their usernames and roles. Requires admin role.",
)
def get_users():
exports = (
User.select(User.username, User.role).order_by(User.username).dicts().iterator()
@@ -760,7 +785,12 @@ def get_users():
return JSONResponse([e for e in exports])
@router.post("/users", dependencies=[Depends(require_role(["admin"]))])
@router.post(
"/users",
dependencies=[Depends(require_role(["admin"]))],
summary="Create new user",
description="Creates a new user with the specified username, password, and role. Requires admin role. Password must meet strength requirements.",
)
def create_user(
request: Request,
body: AppPostUsersBody,
@@ -789,7 +819,12 @@ def create_user(
return JSONResponse(content={"username": body.username})
@router.delete("/users/{username}", dependencies=[Depends(require_role(["admin"]))])
@router.delete(
"/users/{username}",
dependencies=[Depends(require_role(["admin"]))],
summary="Delete user",
description="Deletes a user by username. The built-in admin user cannot be deleted. Requires admin role.",
)
def delete_user(request: Request, username: str):
# Prevent deletion of the built-in admin user
if username == "admin":
@@ -802,7 +837,10 @@ def delete_user(request: Request, username: str):
@router.put(
"/users/{username}/password", dependencies=[Depends(allow_any_authenticated())]
"/users/{username}/password",
dependencies=[Depends(allow_any_authenticated())],
summary="Update user password",
description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity. Password must meet strength requirements (minimum 8 characters, uppercase letter, digit, and special character).",
)
async def update_password(
request: Request,
@@ -887,6 +925,8 @@ async def update_password(
@router.put(
"/users/{username}/role",
dependencies=[Depends(require_role(["admin"]))],
summary="Update user role",
description="Updates a user's role. The built-in admin user's role cannot be modified. Requires admin role.",
)
async def update_role(
request: Request,
@@ -0,0 +1,35 @@
from typing import Optional
from pydantic import BaseModel, Field
class ExportCaseCreateBody(BaseModel):
"""Request body for creating a new export case."""
name: str = Field(max_length=100, description="Friendly name of the export case")
description: Optional[str] = Field(
default=None, description="Optional description of the export case"
)
class ExportCaseUpdateBody(BaseModel):
"""Request body for updating an existing export case."""
name: Optional[str] = Field(
default=None,
max_length=100,
description="Updated friendly name of the export case",
)
description: Optional[str] = Field(
default=None, description="Updated description of the export case"
)
class ExportCaseAssignBody(BaseModel):
"""Request body for assigning or unassigning an export to a case."""
export_case_id: Optional[str] = Field(
default=None,
max_length=30,
description="Case ID to assign to the export, or null to unassign",
)
@@ -1,4 +1,4 @@
from typing import Union
from typing import Optional, Union
from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema
@@ -18,3 +18,9 @@ class ExportRecordingsBody(BaseModel):
)
name: str = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None
export_case_id: Optional[str] = Field(
default=None,
title="Export case ID",
max_length=30,
description="ID of the export case to assign this export to",
)
@@ -0,0 +1,22 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class ExportCaseModel(BaseModel):
"""Model representing a single export case."""
id: str = Field(description="Unique identifier for the export case")
name: str = Field(description="Friendly name of the export case")
description: Optional[str] = Field(
default=None, description="Optional description of the export case"
)
created_at: float = Field(
description="Unix timestamp when the export case was created"
)
updated_at: float = Field(
description="Unix timestamp when the export case was last updated"
)
ExportCasesResponse = List[ExportCaseModel]
@@ -15,6 +15,9 @@ class ExportModel(BaseModel):
in_progress: bool = Field(
description="Whether the export is currently being processed"
)
export_case_id: Optional[str] = Field(
default=None, description="ID of the export case this export belongs to"
)
class StartExportResponse(BaseModel):
+194 -10
View File
@@ -4,10 +4,10 @@ import logging
import random
import string
from pathlib import Path
from typing import List
from typing import List, Optional
import psutil
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filepath
from peewee import DoesNotExist
@@ -19,8 +19,17 @@ from frigate.api.auth import (
require_camera_access,
require_role,
)
from frigate.api.defs.request.export_case_body import (
ExportCaseAssignBody,
ExportCaseCreateBody,
ExportCaseUpdateBody,
)
from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody
from frigate.api.defs.request.export_rename_body import ExportRenameBody
from frigate.api.defs.response.export_case_response import (
ExportCaseModel,
ExportCasesResponse,
)
from frigate.api.defs.response.export_response import (
ExportModel,
ExportsResponse,
@@ -29,7 +38,7 @@ from frigate.api.defs.response.export_response import (
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.const import CLIPS_DIR, EXPORT_DIR
from frigate.models import Export, Previews, Recordings
from frigate.models import Export, ExportCase, Previews, Recordings
from frigate.record.export import (
PlaybackFactorEnum,
PlaybackSourceEnum,
@@ -52,17 +61,181 @@ router = APIRouter(tags=[Tags.export])
)
def get_exports(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
export_case_id: Optional[str] = None,
camera: Optional[List[str]] = Query(default=None),
start_date: Optional[float] = None,
end_date: Optional[float] = None,
):
exports = (
Export.select()
.where(Export.camera << allowed_cameras)
.order_by(Export.date.desc())
.dicts()
.iterator()
)
query = Export.select().where(Export.camera << allowed_cameras)
if export_case_id is not None:
if export_case_id == "unassigned":
query = query.where(Export.export_case.is_null(True))
else:
query = query.where(Export.export_case == export_case_id)
if camera:
filtered_cameras = [c for c in camera if c in allowed_cameras]
if not filtered_cameras:
return JSONResponse(content=[])
query = query.where(Export.camera << filtered_cameras)
if start_date is not None:
query = query.where(Export.date >= start_date)
if end_date is not None:
query = query.where(Export.date <= end_date)
exports = query.order_by(Export.date.desc()).dicts().iterator()
return JSONResponse(content=[e for e in exports])
@router.get(
"/cases",
response_model=ExportCasesResponse,
dependencies=[Depends(allow_any_authenticated())],
summary="Get export cases",
description="Gets all export cases from the database.",
)
def get_export_cases():
cases = (
ExportCase.select().order_by(ExportCase.created_at.desc()).dicts().iterator()
)
return JSONResponse(content=[c for c in cases])
@router.post(
"/cases",
response_model=ExportCaseModel,
dependencies=[Depends(require_role(["admin"]))],
summary="Create export case",
description="Creates a new export case.",
)
def create_export_case(body: ExportCaseCreateBody):
case = ExportCase.create(
id="".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
name=body.name,
description=body.description,
created_at=Path().stat().st_mtime,
updated_at=Path().stat().st_mtime,
)
return JSONResponse(content=model_to_dict(case))
@router.get(
"/cases/{case_id}",
response_model=ExportCaseModel,
dependencies=[Depends(allow_any_authenticated())],
summary="Get a single export case",
description="Gets a specific export case by ID.",
)
def get_export_case(case_id: str):
try:
case = ExportCase.get(ExportCase.id == case_id)
return JSONResponse(content=model_to_dict(case))
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
@router.patch(
"/cases/{case_id}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Update export case",
description="Updates an existing export case.",
)
def update_export_case(case_id: str, body: ExportCaseUpdateBody):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
if body.name is not None:
case.name = body.name
if body.description is not None:
case.description = body.description
case.save()
return JSONResponse(
content={"success": True, "message": "Successfully updated export case."}
)
@router.delete(
"/cases/{case_id}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Delete export case",
description="""Deletes an export case.\n Exports that reference this case will have their export_case set to null.\n """,
)
def delete_export_case(case_id: str):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
# Unassign exports from this case but keep the exports themselves
Export.update(export_case=None).where(Export.export_case == case).execute()
case.delete_instance()
return JSONResponse(
content={"success": True, "message": "Successfully deleted export case."}
)
@router.patch(
"/export/{export_id}/case",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Assign export to case",
description=(
"Assigns an export to a case, or unassigns it if export_case_id is null."
),
)
async def assign_export_case(
export_id: str,
body: ExportCaseAssignBody,
request: Request,
):
try:
export: Export = Export.get(Export.id == export_id)
await require_camera_access(export.camera, request=request)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export not found."},
status_code=404,
)
if body.export_case_id is not None:
try:
ExportCase.get(ExportCase.id == body.export_case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found."},
status_code=404,
)
export.export_case = body.export_case_id
else:
export.export_case = None
export.save()
return JSONResponse(
content={"success": True, "message": "Successfully updated export case."}
)
@router.post(
"/export/{camera_name}/start/{start_time}/end/{end_time}",
response_model=StartExportResponse,
@@ -93,6 +266,16 @@ def export_recording(
friendly_name = body.name
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
export_case_id = body.export_case_id
if export_case_id is not None:
try:
ExportCase.get(ExportCase.id == export_case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
# Ensure that existing_image is a valid path
if existing_image and not existing_image.startswith(CLIPS_DIR):
return JSONResponse(
@@ -161,6 +344,7 @@ def export_recording(
if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings
),
export_case_id,
)
exporter.start()
return JSONResponse(
+4
View File
@@ -19,6 +19,8 @@ class CameraMetrics:
process_pid: Synchronized
capture_process_pid: Synchronized
ffmpeg_pid: Synchronized
reconnects_last_hour: Synchronized
stalls_last_hour: Synchronized
def __init__(self, manager: SyncManager):
self.camera_fps = manager.Value("d", 0)
@@ -35,6 +37,8 @@ class CameraMetrics:
self.process_pid = manager.Value("i", 0)
self.capture_process_pid = manager.Value("i", 0)
self.ffmpeg_pid = manager.Value("i", 0)
self.reconnects_last_hour = manager.Value("i", 0)
self.stalls_last_hour = manager.Value("i", 0)
class PTZMetrics:
+1 -1
View File
@@ -153,7 +153,7 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {2}",
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {2}",
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v main -level:v 4.1 -async_depth:v 1 {2}",
FFMPEG_HWACCEL_NVIDIA: "{0} -hide_banner {1} -hwaccel device {3} -c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll {2}",
FFMPEG_HWACCEL_NVIDIA: "{0} -hide_banner {1} -hwaccel cuda -hwaccel_device {3} -c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll {2}",
"preset-jetson-h264": "{0} -hide_banner {1} -c:v h264_nvmpi -profile high {2}",
"preset-jetson-h265": "{0} -hide_banner {1} -c:v h264_nvmpi -profile main {2}",
FFMPEG_HWACCEL_RKMPP: "{0} -hide_banner {1} -c:v h264_rkmpp -profile:v high {2}",
+14
View File
@@ -80,6 +80,14 @@ class Recordings(Model):
regions = IntegerField(null=True)
class ExportCase(Model):
id = CharField(null=False, primary_key=True, max_length=30)
name = CharField(index=True, max_length=100)
description = TextField(null=True)
created_at = DateTimeField()
updated_at = DateTimeField()
class Export(Model):
id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20)
@@ -88,6 +96,12 @@ class Export(Model):
video_path = CharField(unique=True)
thumb_path = CharField(unique=True)
in_progress = BooleanField()
export_case = ForeignKeyField(
ExportCase,
null=True,
backref="exports",
column_name="export_case_id",
)
class ReviewSegment(Model):
+16 -11
View File
@@ -64,6 +64,7 @@ class RecordingExporter(threading.Thread):
end_time: int,
playback_factor: PlaybackFactorEnum,
playback_source: PlaybackSourceEnum,
export_case_id: Optional[str] = None,
) -> None:
super().__init__()
self.config = config
@@ -75,6 +76,7 @@ class RecordingExporter(threading.Thread):
self.end_time = end_time
self.playback_factor = playback_factor
self.playback_source = playback_source
self.export_case_id = export_case_id
# ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
@@ -348,17 +350,20 @@ class RecordingExporter(threading.Thread):
video_path = f"{EXPORT_DIR}/{self.camera}_{filename_start_datetime}-{filename_end_datetime}_{cleaned_export_id}.mp4"
thumb_path = self.save_thumbnail(self.export_id)
Export.insert(
{
Export.id: self.export_id,
Export.camera: self.camera,
Export.name: export_name,
Export.date: self.start_time,
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: True,
}
).execute()
export_values = {
Export.id: self.export_id,
Export.camera: self.camera,
Export.name: export_name,
Export.date: self.start_time,
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: True,
}
if self.export_case_id is not None:
export_values[Export.export_case] = self.export_case_id
Export.insert(export_values).execute()
try:
if self.playback_source == PlaybackSourceEnum.recordings:
+27
View File
@@ -279,6 +279,32 @@ def stats_snapshot(
if camera_stats.capture_process_pid.value
else None
)
# Calculate connection quality based on current state
# This is computed at stats-collection time so offline cameras
# correctly show as unusable rather than excellent
expected_fps = config.cameras[name].detect.fps
current_fps = camera_stats.camera_fps.value
reconnects = camera_stats.reconnects_last_hour.value
stalls = camera_stats.stalls_last_hour.value
if current_fps < 0.1:
quality_str = "unusable"
elif reconnects == 0 and current_fps >= 0.9 * expected_fps and stalls < 5:
quality_str = "excellent"
elif reconnects <= 2 and current_fps >= 0.6 * expected_fps:
quality_str = "fair"
elif reconnects > 10 or current_fps < 1.0 or stalls > 100:
quality_str = "unusable"
else:
quality_str = "poor"
connection_quality = {
"connection_quality": quality_str,
"expected_fps": expected_fps,
"reconnects_last_hour": reconnects,
"stalls_last_hour": stalls,
}
stats["cameras"][name] = {
"camera_fps": round(camera_stats.camera_fps.value, 2),
"process_fps": round(camera_stats.process_fps.value, 2),
@@ -290,6 +316,7 @@ def stats_snapshot(
"ffmpeg_pid": ffmpeg_pid,
"audio_rms": round(camera_stats.audio_rms.value, 4),
"audio_dBFS": round(camera_stats.audio_dBFS.value, 4),
**connection_quality,
}
stats["detectors"] = {}
+11 -6
View File
@@ -584,12 +584,17 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
"""Run vainfo."""
ffprobe_cmd = (
["vainfo"]
if not device_name
else ["vainfo", "--display", "drm", "--device", f"/dev/dri/{device_name}"]
)
return sp.run(ffprobe_cmd, capture_output=True)
if not device_name:
cmd = ["vainfo"]
else:
if os.path.isabs(device_name) and device_name.startswith("/dev/dri/"):
device_path = device_name
else:
device_path = f"/dev/dri/{device_name}"
cmd = ["vainfo", "--display", "drm", "--device", device_path]
return sp.run(cmd, capture_output=True)
def get_nvidia_driver_info() -> dict[str, Any]:
+102 -19
View File
@@ -3,6 +3,7 @@ import queue
import subprocess as sp
import threading
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent
@@ -115,6 +116,7 @@ def capture_frames(
frame_rate.start()
skipped_eps = EventsPerSecond()
skipped_eps.start()
config_subscriber = CameraConfigUpdateSubscriber(
None, {config.name: config}, [CameraConfigUpdateEnum.enabled]
)
@@ -179,6 +181,9 @@ class CameraWatchdog(threading.Thread):
camera_fps,
skipped_fps,
ffmpeg_pid,
stalls,
reconnects,
detection_frame,
stop_event,
):
threading.Thread.__init__(self)
@@ -199,6 +204,10 @@ class CameraWatchdog(threading.Thread):
self.frame_index = 0
self.stop_event = stop_event
self.sleeptime = self.config.ffmpeg.retry_interval
self.reconnect_timestamps = deque()
self.stalls = stalls
self.reconnects = reconnects
self.detection_frame = detection_frame
self.config_subscriber = CameraConfigUpdateSubscriber(
None,
@@ -213,6 +222,35 @@ class CameraWatchdog(threading.Thread):
self.latest_invalid_segment_time: float = 0
self.latest_cache_segment_time: float = 0
# Stall tracking (based on last processed frame)
self._stall_timestamps: deque[float] = deque()
self._stall_active: bool = False
# Status caching to reduce message volume
self._last_detect_status: str | None = None
self._last_record_status: str | None = None
self._last_status_update_time: float = 0.0
def _send_detect_status(self, status: str, now: float) -> None:
"""Send detect status only if changed or retry_interval has elapsed."""
if (
status != self._last_detect_status
or (now - self._last_status_update_time) >= self.sleeptime
):
self.requestor.send_data(f"{self.config.name}/status/detect", status)
self._last_detect_status = status
self._last_status_update_time = now
def _send_record_status(self, status: str, now: float) -> None:
"""Send record status only if changed or retry_interval has elapsed."""
if (
status != self._last_record_status
or (now - self._last_status_update_time) >= self.sleeptime
):
self.requestor.send_data(f"{self.config.name}/status/record", status)
self._last_record_status = status
self._last_status_update_time = now
def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state."""
self.config_subscriber.check_for_updates()
@@ -239,6 +277,14 @@ class CameraWatchdog(threading.Thread):
else:
self.ffmpeg_detect_process.wait()
# Update reconnects
now = datetime.now().timestamp()
self.reconnect_timestamps.append(now)
while self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600:
self.reconnect_timestamps.popleft()
if self.reconnects:
self.reconnects.value = len(self.reconnect_timestamps)
# Wait for old capture thread to fully exit before starting a new one
if self.capture_thread is not None and self.capture_thread.is_alive():
self.logger.info("Waiting for capture thread to exit...")
@@ -261,7 +307,10 @@ class CameraWatchdog(threading.Thread):
self.start_all_ffmpeg()
time.sleep(self.sleeptime)
while not self.stop_event.wait(self.sleeptime):
last_restart_time = datetime.now().timestamp()
# 1 second watchdog loop
while not self.stop_event.wait(1):
enabled = self._update_enabled_state()
if enabled != self.was_enabled:
if enabled:
@@ -277,12 +326,9 @@ class CameraWatchdog(threading.Thread):
self.stop_all_ffmpeg()
# update camera status
self.requestor.send_data(
f"{self.config.name}/status/detect", "disabled"
)
self.requestor.send_data(
f"{self.config.name}/status/record", "disabled"
)
now = datetime.now().timestamp()
self._send_detect_status("disabled", now)
self._send_record_status("disabled", now)
self.was_enabled = enabled
continue
@@ -321,36 +367,44 @@ class CameraWatchdog(threading.Thread):
now = datetime.now().timestamp()
# Check if enough time has passed to allow ffmpeg restart (backoff pacing)
time_since_last_restart = now - last_restart_time
can_restart = time_since_last_restart >= self.sleeptime
if not self.capture_thread.is_alive():
self.requestor.send_data(f"{self.config.name}/status/detect", "offline")
self._send_detect_status("offline", now)
self.camera_fps.value = 0
self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
)
self.reset_capture_thread(terminate=False)
if can_restart:
self.reset_capture_thread(terminate=False)
last_restart_time = now
elif self.camera_fps.value >= (self.config.detect.fps + 10):
self.fps_overflow_count += 1
if self.fps_overflow_count == 3:
self.requestor.send_data(
f"{self.config.name}/status/detect", "offline"
)
self._send_detect_status("offline", now)
self.fps_overflow_count = 0
self.camera_fps.value = 0
self.logger.info(
f"{self.config.name} exceeded fps limit. Exiting ffmpeg..."
)
self.reset_capture_thread(drain_output=False)
if can_restart:
self.reset_capture_thread(drain_output=False)
last_restart_time = now
elif now - self.capture_thread.current_frame.value > 20:
self.requestor.send_data(f"{self.config.name}/status/detect", "offline")
self._send_detect_status("offline", now)
self.camera_fps.value = 0
self.logger.info(
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
)
self.reset_capture_thread()
if can_restart:
self.reset_capture_thread()
last_restart_time = now
else:
# process is running normally
self.requestor.send_data(f"{self.config.name}/status/detect", "online")
self._send_detect_status("online", now)
self.fps_overflow_count = 0
for p in self.ffmpeg_other_processes:
@@ -421,9 +475,7 @@ class CameraWatchdog(threading.Thread):
continue
else:
self.requestor.send_data(
f"{self.config.name}/status/record", "online"
)
self._send_record_status("online", now)
p["latest_segment_time"] = self.latest_cache_segment_time
if poll is None:
@@ -439,6 +491,34 @@ class CameraWatchdog(threading.Thread):
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
)
# Update stall metrics based on last processed frame timestamp
now = datetime.now().timestamp()
processed_ts = (
float(self.detection_frame.value) if self.detection_frame else 0.0
)
if processed_ts > 0:
delta = now - processed_ts
observed_fps = (
self.camera_fps.value
if self.camera_fps.value > 0
else self.config.detect.fps
)
interval = 1.0 / max(observed_fps, 0.1)
stall_threshold = max(2.0 * interval, 2.0)
if delta > stall_threshold:
if not self._stall_active:
self._stall_timestamps.append(now)
self._stall_active = True
else:
self._stall_active = False
while self._stall_timestamps and self._stall_timestamps[0] < now - 3600:
self._stall_timestamps.popleft()
if self.stalls:
self.stalls.value = len(self._stall_timestamps)
self.stop_all_ffmpeg()
self.logpipe.close()
self.config_subscriber.stop()
@@ -576,6 +656,9 @@ class CameraCapture(FrigateProcess):
self.camera_metrics.camera_fps,
self.camera_metrics.skipped_fps,
self.camera_metrics.ffmpeg_pid,
self.camera_metrics.stalls_last_hour,
self.camera_metrics.reconnects_last_hour,
self.camera_metrics.detection_frame,
self.stop_event,
)
camera_watchdog.start()
@@ -0,0 +1,50 @@
"""Peewee migrations -- 033_create_export_case_table.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
"""
CREATE TABLE IF NOT EXISTS "exportcase" (
"id" VARCHAR(30) NOT NULL PRIMARY KEY,
"name" VARCHAR(100) NOT NULL,
"description" TEXT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL
)
"""
)
migrator.sql(
'CREATE INDEX IF NOT EXISTS "exportcase_name" ON "exportcase" ("name")'
)
migrator.sql(
'CREATE INDEX IF NOT EXISTS "exportcase_created_at" ON "exportcase" ("created_at")'
)
def rollback(migrator, database, fake=False, **kwargs):
pass
@@ -0,0 +1,40 @@
"""Peewee migrations -- 034_add_export_case_to_exports.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
# Add nullable export_case_id column to export table
migrator.sql('ALTER TABLE "export" ADD COLUMN "export_case_id" VARCHAR(30) NULL')
# Index for faster case-based queries
migrator.sql(
'CREATE INDEX IF NOT EXISTS "export_export_case_id" ON "export" ("export_case_id")'
)
def rollback(migrator, database, fake=False, **kwargs):
pass
+16 -2
View File
@@ -2,6 +2,10 @@
"documentTitle": "Export - Frigate",
"search": "Search",
"noExports": "No exports found",
"headings": {
"cases": "Cases",
"uncategorizedExports": "Uncategorized Exports"
},
"deleteExport": "Delete Export",
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
"editExport": {
@@ -13,11 +17,21 @@
"shareExport": "Share export",
"downloadVideo": "Download video",
"editName": "Edit name",
"deleteExport": "Delete export"
"deleteExport": "Delete export",
"assignToCase": "Add to case"
},
"toast": {
"error": {
"renameExportFailed": "Failed to rename export: {{errorMessage}}"
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
}
},
"caseDialog": {
"title": "Add to case",
"description": "Choose an existing case or create a new one.",
"selectLabel": "Case",
"newCaseOption": "Create new case",
"nameLabel": "Case name",
"descriptionLabel": "Description"
}
}
+11
View File
@@ -151,6 +151,17 @@
"cameraDetectionsPerSecond": "{{camName}} detections per second",
"cameraSkippedDetectionsPerSecond": "{{camName}} skipped detections per second"
},
"connectionQuality": {
"title": "Connection Quality",
"excellent": "Excellent",
"fair": "Fair",
"poor": "Poor",
"unusable": "Unusable",
"fps": "FPS",
"expectedFps": "Expected FPS",
"reconnectsLastHour": "Reconnects (last hour)",
"stallsLastHour": "Stalls (last hour)"
},
"toast": {
"success": {
"copyToClipboard": "Copied probe data to clipboard."
+2 -2
View File
@@ -116,10 +116,10 @@ export default function Statusbar() {
}
return (
<Link key={gpuTitle} to="/system#general">
<Link key={name} to="/system#general">
{" "}
<div
key={gpuTitle}
key={name}
className="flex cursor-pointer items-center gap-2 text-sm hover:underline"
>
<MdCircle
@@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type ConnectionQualityIndicatorProps = {
quality: "excellent" | "fair" | "poor" | "unusable";
expectedFps: number;
reconnects: number;
stalls: number;
};
export function ConnectionQualityIndicator({
quality,
expectedFps,
reconnects,
stalls,
}: ConnectionQualityIndicatorProps) {
const { t } = useTranslation(["views/system"]);
const getColorClass = (quality: string): string => {
switch (quality) {
case "excellent":
return "bg-success";
case "fair":
return "bg-yellow-500";
case "poor":
return "bg-orange-500";
case "unusable":
return "bg-destructive";
default:
return "bg-gray-500";
}
};
const qualityLabel = t(`cameras.connectionQuality.${quality}`);
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-block size-3 cursor-pointer rounded-full",
getColorClass(quality),
)}
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="space-y-2">
<div className="font-semibold">
{t("cameras.connectionQuality.title")}
</div>
<div className="text-sm">
<div className="capitalize">{qualityLabel}</div>
<div className="mt-2 space-y-1 text-xs">
<div>
{t("cameras.connectionQuality.expectedFps")}:{" "}
{expectedFps.toFixed(1)} {t("cameras.connectionQuality.fps")}
</div>
<div>
{t("cameras.connectionQuality.reconnectsLastHour")}:{" "}
{reconnects}
</div>
<div>
{t("cameras.connectionQuality.stallsLastHour")}: {stalls}
</div>
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
);
}
+117 -96
View File
@@ -1,9 +1,8 @@
import ActivityIndicator from "../indicators/activity-indicator";
import { LuTrash } from "react-icons/lu";
import { Button } from "../ui/button";
import { useCallback, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
import { isMobile } from "react-device-detect";
import { FiMoreVertical } from "react-icons/fi";
import { Skeleton } from "../ui/skeleton";
import {
Dialog,
@@ -14,35 +13,62 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { DeleteClipType, Export } from "@/types/export";
import { MdEditSquare } from "react-icons/md";
import { DeleteClipType, Export, ExportCase } from "@/types/export";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
import { useTranslation } from "react-i18next";
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
import BlurredIconButton from "../button/BlurredIconButton";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useIsAdmin } from "@/hooks/use-is-admin";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { FaFolder } from "react-icons/fa";
type ExportProps = {
type CaseCardProps = {
className: string;
exportCase: ExportCase;
onSelect: () => void;
};
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
return (
<div
className={cn(
"relative flex aspect-video size-full cursor-pointer items-center justify-center rounded-lg bg-secondary md:rounded-2xl",
className,
)}
onClick={() => onSelect()}
>
<div className="absolute bottom-2 left-2 flex items-center justify-start gap-2">
<FaFolder />
<div className="capitalize">{exportCase.name}</div>
</div>
</div>
);
}
type ExportCardProps = {
className: string;
exportedRecording: Export;
onSelect: (selected: Export) => void;
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
};
export default function ExportCard({
export function ExportCard({
className,
exportedRecording,
onSelect,
onRename,
onDelete,
}: ExportProps) {
onAssignToCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin();
const [hovered, setHovered] = useState(false);
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
@@ -136,12 +162,14 @@ export default function ExportCard({
<div
className={cn(
"relative flex aspect-video items-center justify-center rounded-lg bg-black md:rounded-2xl",
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
className,
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
onClick={isDesktop ? undefined : () => setHovered(!hovered)}
onClick={() => {
if (!exportedRecording.in_progress) {
onSelect(exportedRecording);
}
}}
>
{exportedRecording.in_progress ? (
<ActivityIndicator />
@@ -158,95 +186,88 @@ export default function ExportCard({
)}
</>
)}
{hovered && (
<>
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
<div className="absolute right-3 top-2">
<div className="flex items-center justify-center gap-4">
{!exportedRecording.in_progress && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={() =>
shareOrCopy(
`${baseUrl}export?id=${exportedRecording.id}`,
exportedRecording.name.replaceAll("_", " "),
)
}
>
<FaShareAlt className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.shareExport")}</TooltipContent>
</Tooltip>
)}
{!exportedRecording.in_progress && (
{!exportedRecording.in_progress && (
<div className="absolute bottom-2 right-3 z-40">
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<BlurredIconButton
aria-label={t("tooltip.editName")}
onClick={(e) => e.stopPropagation()}
>
<FiMoreVertical className="size-5" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.shareExport")}
onClick={(e) => {
e.stopPropagation();
shareOrCopy(
`${baseUrl}export?id=${exportedRecording.id}`,
exportedRecording.name.replaceAll("_", " "),
);
}}
>
{t("tooltip.shareExport")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.downloadVideo")}
>
<a
download
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
onClick={(e) => e.stopPropagation()}
>
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton>
<FaDownload className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("tooltip.downloadVideo")}
</TooltipContent>
</Tooltip>
{t("tooltip.downloadVideo")}
</a>
)}
{isAdmin && !exportedRecording.in_progress && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={() =>
setEditName({
original: exportedRecording.name,
update: undefined,
})
}
>
<MdEditSquare className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
</Tooltip>
</DropdownMenuItem>
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.assignToCase")}
onClick={(e) => {
e.stopPropagation();
onAssignToCase(exportedRecording);
}}
>
{t("tooltip.assignToCase")}
</DropdownMenuItem>
)}
{isAdmin && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={() =>
onDelete({
file: exportedRecording.id,
exportName: exportedRecording.name,
})
}
>
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
</Tooltip>
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.editName")}
onClick={(e) => {
e.stopPropagation();
setEditName({
original: exportedRecording.name,
update: undefined,
});
}}
>
{t("tooltip.editName")}
</DropdownMenuItem>
)}
</div>
</div>
{!exportedRecording.in_progress && (
<Button
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
aria-label={t("button.play", { ns: "common" })}
variant="ghost"
onClick={() => {
onSelect(exportedRecording);
}}
>
<FaPlay />
</Button>
)}
</>
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.deleteExport")}
onClick={(e) => {
e.stopPropagation();
onDelete({
file: exportedRecording.id,
exportName: exportedRecording.name,
});
}}
>
{t("tooltip.deleteExport")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
@@ -315,7 +315,7 @@ export default function Step1NameAndDefine({
<FormLabel className="text-primary-variant">
{t("wizard.step1.classificationType")}
</FormLabel>
<Popover>
<Popover modal={true}>
<PopoverTrigger asChild>
<Button
variant="ghost"
@@ -398,7 +398,7 @@ export default function Step1NameAndDefine({
? t("wizard.step1.states")
: t("wizard.step1.classes")}
</FormLabel>
<Popover>
<Popover modal={true}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
@@ -0,0 +1,166 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type Option = {
value: string;
label: string;
};
type OptionAndInputDialogProps = {
open: boolean;
title: string;
description?: string;
options: Option[];
newValueKey: string;
initialValue?: string;
nameLabel: string;
descriptionLabel: string;
setOpen: (open: boolean) => void;
onSave: (value: string) => void;
onCreateNew: (name: string, description: string) => void;
};
export default function OptionAndInputDialog({
open,
title,
description,
options,
newValueKey,
initialValue,
nameLabel,
descriptionLabel,
setOpen,
onSave,
onCreateNew,
}: OptionAndInputDialogProps) {
const { t } = useTranslation("common");
const firstOption = useMemo(() => options[0]?.value, [options]);
const [selectedValue, setSelectedValue] = useState<string | undefined>(
initialValue ?? firstOption,
);
const [name, setName] = useState("");
const [descriptionValue, setDescriptionValue] = useState("");
useEffect(() => {
if (open) {
setSelectedValue(initialValue ?? firstOption);
setName("");
setDescriptionValue("");
}
}, [open, initialValue, firstOption]);
const isNew = selectedValue === newValueKey;
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
const handleSave = () => {
if (!selectedValue) {
return;
}
const trimmedName = name.trim();
const trimmedDescription = descriptionValue.trim();
if (isNew) {
onCreateNew(trimmedName, trimmedDescription);
} else {
onSave(selectedValue);
}
setOpen(false);
};
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
<DialogContent
className={cn("space-y-4", isMobile && "px-4")}
onOpenAutoFocus={(e) => {
if (isMobile) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-2">
<Select
value={selectedValue}
onValueChange={(val) => setSelectedValue(val)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isNew && (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Input
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
/>
</div>
</div>
)}
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
<Button
type="button"
variant="outline"
onClick={() => {
setOpen(false);
}}
>
{t("button.cancel")}
</Button>
<Button
type="button"
variant="select"
disabled={disableSave}
onClick={handleSave}
>
{t("button.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+397 -50
View File
@@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl";
import ExportCard from "@/components/card/ExportCard";
import { CaseCard, ExportCard } from "@/components/card/ExportCard";
import {
AlertDialog,
AlertDialogCancel,
@@ -11,15 +11,24 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export";
import { DeleteClipType, Export, ExportCase } from "@/types/export";
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
@@ -29,32 +38,37 @@ import useSWR from "swr";
function Exports() {
const { t } = useTranslation(["views/exports"]);
const { data: exports, mutate } = useSWR<Export[]>("exports");
useEffect(() => {
document.title = t("documentTitle");
}, [t]);
// Data
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
const { data: rawExports, mutate: updateExports } =
useSWR<Export[]>("exports");
const exports = useMemo<Export[]>(
() => (rawExports ?? []).filter((e) => !e.export_case),
[rawExports],
);
const mutate = useCallback(() => {
updateExports();
updateCases();
}, [updateExports, updateCases]);
// Search
const [search, setSearch] = useState("");
const filteredExports = useMemo(() => {
if (!search || !exports) {
return exports;
}
return exports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [exports, search]);
// Viewing
const [selected, setSelected] = useState<Export>();
const [selectedCaseId, setSelectedCaseId] = useOverlayState<
string | undefined
>("caseId", undefined);
const [selectedAspect, setSelectedAspect] = useState(0.0);
useSearchEffect("id", (id) => {
@@ -66,9 +80,25 @@ function Exports() {
return true;
});
// Deleting
useSearchEffect("caseId", (caseId: string) => {
if (!cases) {
return false;
}
const exists = cases.some((c) => c.id === caseId);
if (!exists) {
return false;
}
setSelectedCaseId(caseId);
return true;
});
// Modifying
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
const onHandleDelete = useCallback(() => {
if (!deleteClip) {
@@ -83,8 +113,6 @@ function Exports() {
});
}, [deleteClip, mutate]);
// Renaming
const onHandleRename = useCallback(
(id: string, update: string) => {
axios
@@ -107,7 +135,7 @@ function Exports() {
});
});
},
[mutate, t],
[mutate, setDeleteClip, t],
);
// Keyboard Listener
@@ -115,10 +143,27 @@ function Exports() {
const contentRef = useRef<HTMLDivElement | null>(null);
useKeyboardListener([], undefined, contentRef);
const selectedCase = useMemo(
() => cases?.find((c) => c.id === selectedCaseId),
[cases, selectedCaseId],
);
const resetCaseDialog = useCallback(() => {
setExportToAssign(undefined);
}, []);
return (
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} />
<CaseAssignmentDialog
exportToAssign={exportToAssign}
cases={cases}
selectedCaseId={selectedCaseId}
onClose={resetCaseDialog}
mutate={mutate}
/>
<AlertDialog
open={deleteClip != undefined}
onOpenChange={() => setDeleteClip(undefined)}
@@ -187,7 +232,7 @@ function Exports() {
</DialogContent>
</Dialog>
{exports && (
{(exports?.length || cases?.length) && (
<div className="flex w-full items-center justify-center p-2">
<Input
className="text-md w-full bg-muted md:w-1/3"
@@ -198,36 +243,338 @@ function Exports() {
</div>
)}
<div className="w-full overflow-hidden">
{exports && filteredExports && filteredExports.length > 0 ? (
<div
ref={contentRef}
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{Object.values(exports).map((item) => (
<ExportCard
key={item.name}
className={
search == "" || filteredExports.includes(item) ? "" : "hidden"
}
exportedRecording={item}
onSelect={setSelected}
onRename={onHandleRename}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
/>
))}
</div>
) : (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
{t("noExports")}
</div>
)}
{selectedCase ? (
<CaseView
contentRef={contentRef}
selectedCase={selectedCase}
exports={rawExports}
search={search}
setSelected={setSelected}
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
/>
) : (
<AllExportsView
contentRef={contentRef}
search={search}
cases={cases}
exports={exports}
setSelectedCaseId={setSelectedCaseId}
setSelected={setSelected}
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
/>
)}
</div>
);
}
type AllExportsViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
search: string;
cases?: ExportCase[];
exports: Export[];
setSelectedCaseId: (id: string) => void;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
};
function AllExportsView({
contentRef,
search,
cases,
exports,
setSelectedCaseId,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
}: AllExportsViewProps) {
const { t } = useTranslation(["views/exports"]);
// Filter
const filteredCases = useMemo(() => {
if (!search || !cases) {
return cases || [];
}
return cases.filter(
(caseItem) =>
caseItem.name.toLowerCase().includes(search.toLowerCase()) ||
(caseItem.description &&
caseItem.description.toLowerCase().includes(search.toLowerCase())),
);
}, [search, cases]);
const filteredExports = useMemo<Export[]>(() => {
if (!search) {
return exports;
}
return exports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [exports, search]);
return (
<div className="w-full overflow-hidden">
{filteredCases?.length || filteredExports.length ? (
<div
ref={contentRef}
className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto"
>
{filteredCases.length > 0 && (
<div className="space-y-2">
<Heading as="h4">{t("headings.cases")}</Heading>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cases?.map((item) => (
<CaseCard
key={item.name}
className={
search == "" || filteredCases?.includes(item)
? ""
: "hidden"
}
exportCase={item}
onSelect={() => {
setSelectedCaseId(item.id);
}}
/>
))}
</div>
</div>
)}
{filteredExports.length > 0 && (
<div className="space-y-4">
<Heading as="h4">{t("headings.uncategorizedExports")}</Heading>
<div
ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{exports.map((item) => (
<ExportCard
key={item.name}
className={
search == "" || filteredExports.includes(item)
? ""
: "hidden"
}
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
onAssignToCase={onAssignToCase}
/>
))}
</div>
</div>
)}
</div>
) : (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
{t("noExports")}
</div>
)}
</div>
);
}
type CaseViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
selectedCase: ExportCase;
exports?: Export[];
search: string;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
};
function CaseView({
contentRef,
selectedCase,
exports,
search,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
}: CaseViewProps) {
const filteredExports = useMemo<Export[]>(() => {
const caseExports = (exports || []).filter(
(e) => e.export_case == selectedCase.id,
);
if (!search) {
return caseExports;
}
return caseExports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [selectedCase, exports, search]);
return (
<div className="flex size-full flex-col gap-8">
<div className="flex flex-col gap-1">
<Heading className="capitalize" as="h2">
{selectedCase.name}
</Heading>
<div className="text-secondary-foreground">
{selectedCase.description}
</div>
</div>
<div
ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{exports?.map((item) => (
<ExportCard
key={item.name}
className={filteredExports.includes(item) ? "" : "hidden"}
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
onAssignToCase={onAssignToCase}
/>
))}
</div>
</div>
);
}
type CaseAssignmentDialogProps = {
exportToAssign?: Export;
cases?: ExportCase[];
selectedCaseId?: string;
onClose: () => void;
mutate: () => void;
};
function CaseAssignmentDialog({
exportToAssign,
cases,
selectedCaseId,
onClose,
mutate,
}: CaseAssignmentDialogProps) {
const { t } = useTranslation(["views/exports"]);
const caseOptions = useMemo(
() => [
...(cases ?? [])
.map((c) => ({
value: c.id,
label: c.name,
}))
.sort((cA, cB) => cA.label.localeCompare(cB.label)),
{
value: "new",
label: t("caseDialog.newCaseOption"),
},
],
[cases, t],
);
const handleSave = useCallback(
async (caseId: string) => {
if (!exportToAssign) return;
try {
await axios.patch(`export/${exportToAssign.id}/case`, {
export_case_id: caseId,
});
mutate();
onClose();
} catch (error: unknown) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
position: "top-center",
});
}
},
[exportToAssign, mutate, onClose, t],
);
const handleCreateNew = useCallback(
async (name: string, description: string) => {
if (!exportToAssign) return;
try {
const createResp = await axios.post("cases", {
name,
description,
});
const newCaseId: string | undefined = createResp.data?.id;
if (newCaseId) {
await axios.patch(`export/${exportToAssign.id}/case`, {
export_case_id: newCaseId,
});
}
mutate();
onClose();
} catch (error: unknown) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
position: "top-center",
});
}
},
[exportToAssign, mutate, onClose, t],
);
if (!exportToAssign) {
return null;
}
return (
<OptionAndInputDialog
open={!!exportToAssign}
title={t("caseDialog.title")}
description={t("caseDialog.description")}
setOpen={(open) => {
if (!open) {
onClose();
}
}}
options={caseOptions}
nameLabel={t("caseDialog.nameLabel")}
descriptionLabel={t("caseDialog.descriptionLabel")}
initialValue={selectedCaseId}
newValueKey="new"
onSave={handleSave}
onCreateNew={handleCreateNew}
/>
);
}
export default Exports;
+9
View File
@@ -6,6 +6,15 @@ export type Export = {
video_path: string;
thumb_path: string;
in_progress: boolean;
export_case?: string;
};
export type ExportCase = {
id: string;
name: string;
description: string;
created_at: number;
updated_at: number;
};
export type DeleteClipType = {
+4
View File
@@ -24,6 +24,10 @@ export type CameraStats = {
pid: number;
process_fps: number;
skipped_fps: number;
connection_quality: "excellent" | "fair" | "poor" | "unusable";
expected_fps: number;
reconnects_last_hour: number;
stalls_last_hour: number;
};
export type CpuStats = {
+32 -2
View File
@@ -1,6 +1,7 @@
import { useFrigateStats } from "@/api/ws";
import { CameraLineGraph } from "@/components/graph/LineGraph";
import CameraInfoDialog from "@/components/overlay/CameraInfoDialog";
import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator";
import { Skeleton } from "@/components/ui/skeleton";
import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateStats } from "@/types/stats";
@@ -282,8 +283,37 @@ export default function CameraMetrics({
)}
<div className="flex w-full flex-col gap-3">
<div className="flex flex-row items-center justify-between">
<div className="text-sm font-medium text-muted-foreground smart-capitalize">
<CameraNameLabel camera={camera} />
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-muted-foreground smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{statsHistory.length > 0 &&
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
] && (
<ConnectionQualityIndicator
quality={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.connection_quality
}
expectedFps={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.expected_fps || 0
}
reconnects={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.reconnects_last_hour || 0
}
stalls={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.stalls_last_hour || 0
}
/>
)}
</div>
<Tooltip>
<TooltipTrigger>