mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-12 08:06:42 +03:00
Compare commits
No commits in common. "c71e235b389566b96b75061c43bdf8226bbdd2bc" and "7a8f93e9f56bb24d5c92287fb5a78017efec24d7" have entirely different histories.
c71e235b38
...
7a8f93e9f5
2023
docs/static/frigate-api.yaml
vendored
2023
docs/static/frigate-api.yaml
vendored
File diff suppressed because it is too large
Load Diff
@ -16,14 +16,8 @@ from playhouse.shortcuts import model_to_dict
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.request.classification_body import (
|
||||
AudioTranscriptionBody,
|
||||
DeleteFaceImagesBody,
|
||||
RenameFaceBody,
|
||||
)
|
||||
from frigate.api.defs.response.classification_response import (
|
||||
FaceRecognitionResponse,
|
||||
FacesResponse,
|
||||
)
|
||||
from frigate.api.defs.response.generic_response import GenericResponse
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera import DetectConfig
|
||||
@ -34,18 +28,10 @@ from frigate.util.path import get_event_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.classification])
|
||||
router = APIRouter(tags=[Tags.events])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/faces",
|
||||
response_model=FacesResponse,
|
||||
summary="Get all registered faces",
|
||||
description="""Returns a dictionary mapping face names to lists of image filenames.
|
||||
Each key represents a registered face name, and the value is a list of image
|
||||
files associated with that face. Supported image formats include .webp, .png,
|
||||
.jpg, and .jpeg.""",
|
||||
)
|
||||
@router.get("/faces")
|
||||
def get_faces():
|
||||
face_dict: dict[str, list[str]] = {}
|
||||
|
||||
@ -69,15 +55,7 @@ def get_faces():
|
||||
return JSONResponse(status_code=200, content=face_dict)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/faces/reprocess",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Reprocess a face training image",
|
||||
description="""Reprocesses a face training image to update the prediction.
|
||||
Requires face recognition to be enabled in the configuration. The training file
|
||||
must exist in the faces/train directory. Returns a success response or an error
|
||||
message if face recognition is not enabled or the training file is invalid.""",
|
||||
)
|
||||
@router.post("/faces/reprocess", dependencies=[Depends(require_role(["admin"]))])
|
||||
def reclassify_face(request: Request, body: dict = None):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
@ -110,17 +88,7 @@ def reclassify_face(request: Request, body: dict = None):
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/faces/train/{name}/classify",
|
||||
response_model=GenericResponse,
|
||||
summary="Classify and save a face training image",
|
||||
description="""Adds a training image to a specific face name for face recognition.
|
||||
Accepts either a training file from the train directory or an event_id to extract
|
||||
the face from. The image is saved to the face's directory and the face classifier
|
||||
is cleared to incorporate the new training data. Returns a success message with
|
||||
the new filename or an error if face recognition is not enabled, the file/event
|
||||
is invalid, or the face cannot be extracted.""",
|
||||
)
|
||||
@router.post("/faces/train/{name}/classify")
|
||||
def train_face(request: Request, name: str, body: dict = None):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
@ -224,16 +192,7 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/faces/{name}/create",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Create a new face name",
|
||||
description="""Creates a new folder for a face name in the faces directory.
|
||||
This is used to organize face training images. The face name is sanitized and
|
||||
spaces are replaced with underscores. Returns a success message or an error if
|
||||
face recognition is not enabled.""",
|
||||
)
|
||||
@router.post("/faces/{name}/create", dependencies=[Depends(require_role(["admin"]))])
|
||||
async def create_face(request: Request, name: str):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
@ -250,16 +209,7 @@ async def create_face(request: Request, name: str):
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/faces/{name}/register",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Register a face image",
|
||||
description="""Registers a face image for a specific face name by uploading an image file.
|
||||
The uploaded image is processed and added to the face recognition system. Returns a
|
||||
success response with details about the registration, or an error if face recognition
|
||||
is not enabled or the image cannot be processed.""",
|
||||
)
|
||||
@router.post("/faces/{name}/register", dependencies=[Depends(require_role(["admin"]))])
|
||||
async def register_face(request: Request, name: str, file: UploadFile):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
@ -285,14 +235,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/faces/recognize",
|
||||
response_model=FaceRecognitionResponse,
|
||||
summary="Recognize a face from an uploaded image",
|
||||
description="""Recognizes a face from an uploaded image file by comparing it against
|
||||
registered faces in the system. Returns the recognized face name and confidence score,
|
||||
or an error if face recognition is not enabled or the image cannot be processed.""",
|
||||
)
|
||||
@router.post("/faces/recognize")
|
||||
async def recognize_face(request: Request, file: UploadFile):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
@ -318,38 +261,28 @@ async def recognize_face(request: Request, file: UploadFile):
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/faces/{name}/delete",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete face images",
|
||||
description="""Deletes specific face images for a given face name. The image IDs must belong
|
||||
to the specified face folder. To delete an entire face folder, all image IDs in that
|
||||
folder must be sent. Returns a success message or an error if face recognition is not enabled.""",
|
||||
)
|
||||
def deregister_faces(request: Request, name: str, body: DeleteFaceImagesBody):
|
||||
@router.post("/faces/{name}/delete", dependencies=[Depends(require_role(["admin"]))])
|
||||
def deregister_faces(request: Request, name: str, body: dict = None):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
context.delete_face_ids(name, map(lambda file: sanitize_filename(file), body.ids))
|
||||
context.delete_face_ids(
|
||||
name, map(lambda file: sanitize_filename(file), list_of_ids)
|
||||
)
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/faces/{old_name}/rename",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Rename a face name",
|
||||
description="""Renames a face name in the system. The old name must exist and the new
|
||||
name must be valid. Returns a success message or an error if face recognition is not enabled.""",
|
||||
)
|
||||
@router.put("/faces/{old_name}/rename", dependencies=[Depends(require_role(["admin"]))])
|
||||
def rename_face(request: Request, old_name: str, body: RenameFaceBody):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
@ -378,14 +311,7 @@ def rename_face(request: Request, old_name: str, body: RenameFaceBody):
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/lpr/reprocess",
|
||||
summary="Reprocess a license plate",
|
||||
description="""Reprocesses a license plate image to update the plate.
|
||||
Requires license plate recognition to be enabled in the configuration. The event_id
|
||||
must exist in the database. Returns a success message or an error if license plate
|
||||
recognition is not enabled or the event_id is invalid.""",
|
||||
)
|
||||
@router.put("/lpr/reprocess")
|
||||
def reprocess_license_plate(request: Request, event_id: str):
|
||||
if not request.app.frigate_config.lpr.enabled:
|
||||
message = "License plate recognition is not enabled."
|
||||
@ -418,14 +344,7 @@ def reprocess_license_plate(request: Request, event_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/reindex",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Reindex embeddings",
|
||||
description="""Reindexes the embeddings for all tracked objects.
|
||||
Requires semantic search to be enabled in the configuration. Returns a success message or an error if semantic search is not enabled.""",
|
||||
)
|
||||
@router.put("/reindex", dependencies=[Depends(require_role(["admin"]))])
|
||||
def reindex_embeddings(request: Request):
|
||||
if not request.app.frigate_config.semantic_search.enabled:
|
||||
message = (
|
||||
@ -471,14 +390,7 @@ def reindex_embeddings(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/audio/transcribe",
|
||||
response_model=GenericResponse,
|
||||
summary="Transcribe audio",
|
||||
description="""Transcribes audio from a specific event.
|
||||
Requires audio transcription to be enabled in the configuration. The event_id
|
||||
must exist in the database. Returns a success message or an error if audio transcription is not enabled or the event_id is invalid.""",
|
||||
)
|
||||
@router.put("/audio/transcribe")
|
||||
def transcribe_audio(request: Request, body: AudioTranscriptionBody):
|
||||
event_id = body.event_id
|
||||
|
||||
@ -536,12 +448,7 @@ def transcribe_audio(request: Request, body: AudioTranscriptionBody):
|
||||
# custom classification training
|
||||
|
||||
|
||||
@router.get(
|
||||
"/classification/{name}/dataset",
|
||||
summary="Get classification dataset",
|
||||
description="""Gets the dataset for a specific classification model.
|
||||
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
@router.get("/classification/{name}/dataset")
|
||||
def get_classification_dataset(name: str):
|
||||
dataset_dict: dict[str, list[str]] = {}
|
||||
|
||||
@ -567,12 +474,7 @@ def get_classification_dataset(name: str):
|
||||
return JSONResponse(status_code=200, content=dataset_dict)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/classification/{name}/train",
|
||||
summary="Get classification train images",
|
||||
description="""Gets the train images for a specific classification model.
|
||||
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
@router.get("/classification/{name}/train")
|
||||
def get_classification_images(name: str):
|
||||
train_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "train")
|
||||
|
||||
@ -590,13 +492,7 @@ def get_classification_images(name: str):
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/classification/{name}/train",
|
||||
response_model=GenericResponse,
|
||||
summary="Train a classification model",
|
||||
description="""Trains a specific classification model.
|
||||
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
@router.post("/classification/{name}/train")
|
||||
async def train_configured_model(request: Request, name: str):
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
@ -621,11 +517,7 @@ async def train_configured_model(request: Request, name: str):
|
||||
|
||||
@router.post(
|
||||
"/classification/{name}/dataset/{category}/delete",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete classification dataset images",
|
||||
description="""Deletes specific dataset images for a given classification model and category.
|
||||
The image IDs must belong to the specified category. Returns a success message or an error if the name or category is invalid.""",
|
||||
)
|
||||
def delete_classification_dataset_images(
|
||||
request: Request, name: str, category: str, body: dict = None
|
||||
@ -663,11 +555,7 @@ def delete_classification_dataset_images(
|
||||
|
||||
@router.post(
|
||||
"/classification/{name}/dataset/categorize",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Categorize a classification image",
|
||||
description="""Categorizes a specific classification image for a given classification model and category.
|
||||
The image must exist in the specified category. Returns a success message or an error if the name or category is invalid.""",
|
||||
)
|
||||
def categorize_classification_image(request: Request, name: str, body: dict = None):
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
@ -722,11 +610,7 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
||||
|
||||
@router.post(
|
||||
"/classification/{name}/train/delete",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete classification train images",
|
||||
description="""Deletes specific train images for a given classification model.
|
||||
The image IDs must belong to the specified train folder. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
def delete_classification_train_images(request: Request, name: str, body: dict = None):
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RenameFaceBody(BaseModel):
|
||||
@ -9,9 +7,3 @@ class RenameFaceBody(BaseModel):
|
||||
|
||||
class AudioTranscriptionBody(BaseModel):
|
||||
event_id: str
|
||||
|
||||
|
||||
class DeleteFaceImagesBody(BaseModel):
|
||||
ids: List[str] = Field(
|
||||
description="List of image filenames to delete from the face folder"
|
||||
)
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
|
||||
|
||||
class FacesResponse(RootModel[Dict[str, List[str]]]):
|
||||
"""Response model for the get_faces endpoint.
|
||||
|
||||
Returns a mapping of face names to lists of image filenames.
|
||||
Each face name corresponds to a directory in the faces folder,
|
||||
and the list contains the names of image files for that face.
|
||||
|
||||
Example:
|
||||
{
|
||||
"john_doe": ["face1.webp", "face2.jpg"],
|
||||
"jane_smith": ["face3.png"]
|
||||
}
|
||||
"""
|
||||
|
||||
root: Dict[str, List[str]] = Field(
|
||||
default_factory=dict,
|
||||
description="Dictionary mapping face names to lists of image filenames",
|
||||
)
|
||||
|
||||
|
||||
class FaceRecognitionResponse(BaseModel):
|
||||
"""Response model for face recognition endpoint.
|
||||
|
||||
Returns the result of attempting to recognize a face from an uploaded image.
|
||||
"""
|
||||
|
||||
success: bool = Field(description="Whether the face recognition was successful")
|
||||
score: Optional[float] = Field(
|
||||
default=None, description="Confidence score of the recognition (0-1)"
|
||||
)
|
||||
face_name: Optional[str] = Field(
|
||||
default=None, description="The recognized face name if successful"
|
||||
)
|
||||
@ -1,30 +0,0 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExportModel(BaseModel):
|
||||
"""Model representing a single export."""
|
||||
|
||||
id: str = Field(description="Unique identifier for the export")
|
||||
camera: str = Field(description="Camera name associated with this export")
|
||||
name: str = Field(description="Friendly name of the export")
|
||||
date: float = Field(description="Unix timestamp when the export was created")
|
||||
video_path: str = Field(description="File path to the exported video")
|
||||
thumb_path: str = Field(description="File path to the export thumbnail")
|
||||
in_progress: bool = Field(
|
||||
description="Whether the export is currently being processed"
|
||||
)
|
||||
|
||||
|
||||
class StartExportResponse(BaseModel):
|
||||
"""Response model for starting an export."""
|
||||
|
||||
success: bool = Field(description="Whether the export was started successfully")
|
||||
message: str = Field(description="Status or error message")
|
||||
export_id: Optional[str] = Field(
|
||||
default=None, description="The export ID if successfully started"
|
||||
)
|
||||
|
||||
|
||||
ExportsResponse = List[ExportModel]
|
||||
@ -1,17 +0,0 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PreviewModel(BaseModel):
|
||||
"""Model representing a single preview clip."""
|
||||
|
||||
camera: str = Field(description="Camera name for this preview")
|
||||
src: str = Field(description="Path to the preview video file")
|
||||
type: str = Field(description="MIME type of the preview video (video/mp4)")
|
||||
start: float = Field(description="Unix timestamp when the preview starts")
|
||||
end: float = Field(description="Unix timestamp when the preview ends")
|
||||
|
||||
|
||||
PreviewsResponse = List[PreviewModel]
|
||||
PreviewFramesResponse = List[str]
|
||||
@ -10,5 +10,5 @@ class Tags(Enum):
|
||||
review = "Review"
|
||||
export = "Export"
|
||||
events = "Events"
|
||||
classification = "Classification"
|
||||
classification = "classification"
|
||||
auth = "Auth"
|
||||
|
||||
@ -65,12 +65,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.events])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events",
|
||||
response_model=list[EventResponse],
|
||||
summary="Get events",
|
||||
description="Returns a list of events.",
|
||||
)
|
||||
@router.get("/events", response_model=list[EventResponse])
|
||||
def events(
|
||||
params: EventsQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
@ -339,14 +334,7 @@ def events(
|
||||
return JSONResponse(content=list(events))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/explore",
|
||||
response_model=list[EventResponse],
|
||||
summary="Get summary of objects.",
|
||||
description="""Gets a summary of objects from the database.
|
||||
Returns a list of objects with a max of `limit` objects for each label.
|
||||
""",
|
||||
)
|
||||
@router.get("/events/explore", response_model=list[EventResponse])
|
||||
def events_explore(
|
||||
limit: int = 10,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
@ -431,14 +419,7 @@ def events_explore(
|
||||
return JSONResponse(content=processed_events)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/event_ids",
|
||||
response_model=list[EventResponse],
|
||||
summary="Get events by ids.",
|
||||
description="""Gets events by a list of ids.
|
||||
Returns a list of events.
|
||||
""",
|
||||
)
|
||||
@router.get("/event_ids", response_model=list[EventResponse])
|
||||
async def event_ids(ids: str, request: Request):
|
||||
ids = ids.split(",")
|
||||
|
||||
@ -465,13 +446,7 @@ async def event_ids(ids: str, request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/search",
|
||||
summary="Search events.",
|
||||
description="""Searches for events in the database.
|
||||
Returns a list of events.
|
||||
""",
|
||||
)
|
||||
@router.get("/events/search")
|
||||
def events_search(
|
||||
request: Request,
|
||||
params: EventsSearchQueryParams = Depends(),
|
||||
@ -857,12 +832,7 @@ def events_summary(
|
||||
return JSONResponse(content=[e for e in groups.dicts()])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}",
|
||||
response_model=EventResponse,
|
||||
summary="Get event by id.",
|
||||
description="Gets an event by its id.",
|
||||
)
|
||||
@router.get("/events/{event_id}", response_model=EventResponse)
|
||||
async def event(event_id: str, request: Request):
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
@ -876,11 +846,6 @@ async def event(event_id: str, request: Request):
|
||||
"/events/{event_id}/retain",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Set event retain indefinitely.",
|
||||
description="""Sets an event to retain indefinitely.
|
||||
Returns a success message or an error if the event is not found.
|
||||
NOTE: This is a legacy endpoint and is not supported in the frontend.
|
||||
""",
|
||||
)
|
||||
def set_retain(event_id: str):
|
||||
try:
|
||||
@ -900,14 +865,7 @@ def set_retain(event_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/events/{event_id}/plus",
|
||||
response_model=EventUploadPlusResponse,
|
||||
summary="Send event to Frigate+.",
|
||||
description="""Sends an event to Frigate+.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
)
|
||||
@router.post("/events/{event_id}/plus", response_model=EventUploadPlusResponse)
|
||||
async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
|
||||
if not request.app.frigate_config.plus_api.is_active():
|
||||
message = "PLUS_API_KEY environment variable is not set"
|
||||
@ -1020,14 +978,7 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/events/{event_id}/false_positive",
|
||||
response_model=EventUploadPlusResponse,
|
||||
summary="Submit false positive to Frigate+",
|
||||
description="""Submit an event as a false positive to Frigate+.
|
||||
This endpoint is the same as the standard Frigate+ submission endpoint,
|
||||
but is specifically for marking an event as a false positive.""",
|
||||
)
|
||||
@router.put("/events/{event_id}/false_positive", response_model=EventUploadPlusResponse)
|
||||
async def false_positive(request: Request, event_id: str):
|
||||
if not request.app.frigate_config.plus_api.is_active():
|
||||
message = "PLUS_API_KEY environment variable is not set"
|
||||
@ -1121,11 +1072,6 @@ async def false_positive(request: Request, event_id: str):
|
||||
"/events/{event_id}/retain",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Stop event from being retained indefinitely.",
|
||||
description="""Stops an event from being retained indefinitely.
|
||||
Returns a success message or an error if the event is not found.
|
||||
NOTE: This is a legacy endpoint and is not supported in the frontend.
|
||||
""",
|
||||
)
|
||||
async def delete_retain(event_id: str, request: Request):
|
||||
try:
|
||||
@ -1150,10 +1096,6 @@ async def delete_retain(event_id: str, request: Request):
|
||||
"/events/{event_id}/sub_label",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Set event sub label.",
|
||||
description="""Sets an event's sub label.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
)
|
||||
async def set_sub_label(
|
||||
request: Request,
|
||||
@ -1209,10 +1151,6 @@ async def set_sub_label(
|
||||
"/events/{event_id}/recognized_license_plate",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Set event license plate.",
|
||||
description="""Sets an event's license plate.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
)
|
||||
async def set_plate(
|
||||
request: Request,
|
||||
@ -1269,10 +1207,6 @@ async def set_plate(
|
||||
"/events/{event_id}/description",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Set event description.",
|
||||
description="""Sets an event's description.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
)
|
||||
async def set_description(
|
||||
request: Request,
|
||||
@ -1325,10 +1259,6 @@ async def set_description(
|
||||
"/events/{event_id}/description/regenerate",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Regenerate event description.",
|
||||
description="""Regenerates an event's description.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
)
|
||||
async def regenerate_description(
|
||||
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
|
||||
@ -1378,10 +1308,6 @@ async def regenerate_description(
|
||||
"/description/generate",
|
||||
response_model=GenericResponse,
|
||||
# dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Generate description embedding.",
|
||||
description="""Generates an embedding for an event's description.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
)
|
||||
def generate_description_embedding(
|
||||
request: Request,
|
||||
@ -1442,10 +1368,6 @@ async def delete_single_event(event_id: str, request: Request) -> dict:
|
||||
"/events/{event_id}",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete event.",
|
||||
description="""Deletes an event from the database.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
)
|
||||
async def delete_event(request: Request, event_id: str):
|
||||
result = await delete_single_event(event_id, request)
|
||||
@ -1457,10 +1379,6 @@ async def delete_event(request: Request, event_id: str):
|
||||
"/events/",
|
||||
response_model=EventMultiDeleteResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete events.",
|
||||
description="""Deletes a list of events from the database.
|
||||
Returns a success message or an error if the events are not found.
|
||||
""",
|
||||
)
|
||||
async def delete_events(request: Request, body: EventsDeleteBody):
|
||||
if not body.event_ids:
|
||||
@ -1491,13 +1409,6 @@ async def delete_events(request: Request, body: EventsDeleteBody):
|
||||
"/events/{camera_name}/{label}/create",
|
||||
response_model=EventCreateResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Create manual event.",
|
||||
description="""Creates a manual event in the database.
|
||||
Returns a success message or an error if the event is not found.
|
||||
NOTES:
|
||||
- Creating a manual event does not trigger an update to /events MQTT topic.
|
||||
- If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end.
|
||||
""",
|
||||
)
|
||||
def create_event(
|
||||
request: Request,
|
||||
@ -1555,11 +1466,6 @@ def create_event(
|
||||
"/events/{event_id}/end",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="End manual event.",
|
||||
description="""Ends a manual event.
|
||||
Returns a success message or an error if the event is not found.
|
||||
NOTE: This should only be used for manual events.
|
||||
""",
|
||||
)
|
||||
async def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||
try:
|
||||
@ -1587,10 +1493,6 @@ async def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||
"/trigger/embedding",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Create trigger embedding.",
|
||||
description="""Creates a trigger embedding for a specific trigger.
|
||||
Returns a success message or an error if the trigger is not found.
|
||||
""",
|
||||
)
|
||||
def create_trigger_embedding(
|
||||
request: Request,
|
||||
@ -1743,10 +1645,6 @@ def create_trigger_embedding(
|
||||
"/trigger/embedding/{camera_name}/{name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Update trigger embedding.",
|
||||
description="""Updates a trigger embedding for a specific trigger.
|
||||
Returns a success message or an error if the trigger is not found.
|
||||
""",
|
||||
)
|
||||
def update_trigger_embedding(
|
||||
request: Request,
|
||||
@ -1908,10 +1806,6 @@ def update_trigger_embedding(
|
||||
"/trigger/embedding/{camera_name}/{name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete trigger embedding.",
|
||||
description="""Deletes a trigger embedding for a specific trigger.
|
||||
Returns a success message or an error if the trigger is not found.
|
||||
""",
|
||||
)
|
||||
def delete_trigger_embedding(
|
||||
request: Request,
|
||||
@ -1983,10 +1877,6 @@ def delete_trigger_embedding(
|
||||
"/triggers/status/{camera_name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Get triggers status.",
|
||||
description="""Gets the status of all triggers for a specific camera.
|
||||
Returns a success message or an error if the camera is not found.
|
||||
""",
|
||||
)
|
||||
def get_triggers_status(
|
||||
camera_name: str,
|
||||
|
||||
@ -19,12 +19,6 @@ from frigate.api.auth import (
|
||||
)
|
||||
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_response import (
|
||||
ExportModel,
|
||||
ExportsResponse,
|
||||
StartExportResponse,
|
||||
)
|
||||
from frigate.api.defs.response.generic_response import GenericResponse
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import EXPORT_DIR
|
||||
from frigate.models import Export, Previews, Recordings
|
||||
@ -40,13 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.export])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports",
|
||||
response_model=ExportsResponse,
|
||||
summary="Get exports",
|
||||
description="""Gets all exports from the database for cameras the user has access to.
|
||||
Returns a list of exports ordered by date (most recent first).""",
|
||||
)
|
||||
@router.get("/exports")
|
||||
def get_exports(
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
@ -62,13 +50,7 @@ def get_exports(
|
||||
|
||||
@router.post(
|
||||
"/export/{camera_name}/start/{start_time}/end/{end_time}",
|
||||
response_model=StartExportResponse,
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
summary="Start recording export",
|
||||
description="""Starts an export of a recording for the specified time range.
|
||||
The export can be from recordings or preview footage. Returns the export ID if
|
||||
successful, or an error message if the camera is invalid or no recordings/previews
|
||||
are found for the time range.""",
|
||||
)
|
||||
def export_recording(
|
||||
request: Request,
|
||||
@ -166,13 +148,7 @@ def export_recording(
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/export/{event_id}/rename",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Rename export",
|
||||
description="""Renames an export.
|
||||
NOTE: This changes the friendly name of the export, not the filename.
|
||||
""",
|
||||
"/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
async def export_rename(event_id: str, body: ExportRenameBody, request: Request):
|
||||
try:
|
||||
@ -202,12 +178,7 @@ async def export_rename(event_id: str, body: ExportRenameBody, request: Request)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/export/{event_id}",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete export",
|
||||
)
|
||||
@router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))])
|
||||
async def export_delete(event_id: str, request: Request):
|
||||
try:
|
||||
export: Export = Export.get(Export.id == event_id)
|
||||
@ -261,13 +232,7 @@ async def export_delete(event_id: str, request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports/{export_id}",
|
||||
response_model=ExportModel,
|
||||
summary="Get a single export",
|
||||
description="""Gets a specific export by ID. The user must have access to the camera
|
||||
associated with the export.""",
|
||||
)
|
||||
@router.get("/exports/{export_id}")
|
||||
async def get_export(export_id: str, request: Request):
|
||||
try:
|
||||
export = Export.get(Export.id == export_id)
|
||||
|
||||
@ -19,13 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.notifications])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notifications/pubkey",
|
||||
summary="Get VAPID public key",
|
||||
description="""Gets the VAPID public key for the notifications.
|
||||
Returns the public key or an error if notifications are not enabled.
|
||||
""",
|
||||
)
|
||||
@router.get("/notifications/pubkey")
|
||||
def get_vapid_pub_key(request: Request):
|
||||
config = request.app.frigate_config
|
||||
notifications_enabled = config.notifications.enabled
|
||||
@ -45,13 +39,7 @@ def get_vapid_pub_key(request: Request):
|
||||
return JSONResponse(content=utils.b64urlencode(raw_pub), status_code=200)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications/register",
|
||||
summary="Register notifications",
|
||||
description="""Registers a notifications subscription.
|
||||
Returns a success message or an error if the subscription is not provided.
|
||||
""",
|
||||
)
|
||||
@router.post("/notifications/register")
|
||||
def register_notifications(request: Request, body: dict = None):
|
||||
if request.app.frigate_config.auth.enabled:
|
||||
# FIXME: For FastAPI the remote-user is not being populated
|
||||
|
||||
@ -9,10 +9,6 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.api.auth import require_camera_access
|
||||
from frigate.api.defs.response.preview_response import (
|
||||
PreviewFramesResponse,
|
||||
PreviewsResponse,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||
from frigate.models import Previews
|
||||
@ -25,13 +21,7 @@ router = APIRouter(tags=[Tags.preview])
|
||||
|
||||
@router.get(
|
||||
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}",
|
||||
response_model=PreviewsResponse,
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
summary="Get preview clips for time range",
|
||||
description="""Gets all preview clips for a specified camera and time range.
|
||||
Returns a list of preview video clips that overlap with the requested time period,
|
||||
ordered by start time. Use camera_name='all' to get previews from all cameras.
|
||||
Returns an error if no previews are found.""",
|
||||
)
|
||||
def preview_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||
"""Get all mp4 previews relevant for time period."""
|
||||
@ -87,13 +77,7 @@ def preview_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||
|
||||
@router.get(
|
||||
"/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}",
|
||||
response_model=PreviewsResponse,
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
summary="Get preview clips for specific hour",
|
||||
description="""Gets all preview clips for a specific hour in a given timezone.
|
||||
Converts the provided date/time from the specified timezone to UTC and retrieves
|
||||
all preview clips for that hour. Use camera_name='all' to get previews from all cameras.
|
||||
The tz_name should be a timezone like 'America/New_York' (use commas instead of slashes).""",
|
||||
)
|
||||
def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
|
||||
"""Get all mp4 previews relevant for time period given the timezone"""
|
||||
@ -111,12 +95,7 @@ def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name
|
||||
|
||||
@router.get(
|
||||
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames",
|
||||
response_model=PreviewFramesResponse,
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
summary="Get cached preview frame filenames",
|
||||
description="""Gets a list of cached preview frame filenames for a specific camera and time range.
|
||||
Returns an array of filenames for preview frames that fall within the specified time period,
|
||||
sorted in chronological order. These are individual frame images cached for quick preview display.""",
|
||||
)
|
||||
def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
|
||||
"""Get list of cached preview frames"""
|
||||
|
||||
@ -371,14 +371,13 @@ class WebPushClient(Communicator):
|
||||
|
||||
sorted_objects.update(payload["after"]["data"]["sub_labels"])
|
||||
|
||||
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
||||
image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}"
|
||||
ended = state == "end" or state == "genai"
|
||||
|
||||
if state == "genai" and payload["after"]["data"]["metadata"]:
|
||||
title = payload["after"]["data"]["metadata"]["title"]
|
||||
message = payload["after"]["data"]["metadata"]["scene"]
|
||||
else:
|
||||
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
||||
message = f"Detected on {camera_name}"
|
||||
|
||||
if ended:
|
||||
|
||||
@ -20,7 +20,6 @@
|
||||
"triggers": "Triggers",
|
||||
"debug": "Debug",
|
||||
"users": "Users",
|
||||
"roles": "Roles",
|
||||
"notifications": "Notifications",
|
||||
"frigateplus": "Frigate+"
|
||||
},
|
||||
|
||||
@ -6,7 +6,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { isDesktop, isIOS, isSafari } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||
import { FaCompactDisc } from "react-icons/fa";
|
||||
@ -36,16 +36,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { buttonVariants } from "../ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ReviewCardProps = {
|
||||
event: ReviewSegment;
|
||||
activeReviewItem?: ReviewSegment;
|
||||
currentTime: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export default function ReviewCard({
|
||||
event,
|
||||
activeReviewItem,
|
||||
currentTime,
|
||||
onClick,
|
||||
}: ReviewCardProps) {
|
||||
const { t } = useTranslation(["components/dialog"]);
|
||||
@ -58,6 +57,12 @@ export default function ReviewCard({
|
||||
: t("time.formattedTimestampHourMinute.12hour", { ns: "common" }),
|
||||
config?.ui.timezone,
|
||||
);
|
||||
const isSelected = useMemo(
|
||||
() =>
|
||||
event.start_time <= currentTime &&
|
||||
(event.end_time ?? Date.now() / 1000) >= currentTime,
|
||||
[event, currentTime],
|
||||
);
|
||||
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@ -134,12 +139,7 @@ export default function ReviewCard({
|
||||
/>
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={cn(
|
||||
"size-full rounded-lg",
|
||||
activeReviewItem?.id == event.id &&
|
||||
"outline outline-[3px] outline-offset-1 outline-selected",
|
||||
imgLoaded ? "visible" : "invisible",
|
||||
)}
|
||||
className={`size-full rounded-lg ${isSelected ? "outline outline-[3px] outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`}
|
||||
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
style={
|
||||
|
||||
@ -170,14 +170,12 @@ export function MobilePageContent({
|
||||
|
||||
interface MobilePageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onClose?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MobilePageHeader({
|
||||
children,
|
||||
className,
|
||||
onClose,
|
||||
actions,
|
||||
...props
|
||||
}: MobilePageHeaderProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
@ -210,11 +208,6 @@ export function MobilePageHeader({
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
</Button>
|
||||
<div className="flex flex-row text-center">{children}</div>
|
||||
{actions && (
|
||||
<div className="absolute right-0 flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,9 +21,8 @@ export function GenAISummaryChip({ review, onClick }: GenAISummaryChipProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-1/2 top-8 z-30 flex max-w-[90vw] -translate-x-[50%] cursor-pointer select-none items-center gap-2 rounded-full p-2 text-sm transition-all duration-500",
|
||||
"absolute left-1/2 top-8 z-30 flex max-w-[90vw] -translate-x-[50%] cursor-pointer select-none items-center gap-2 rounded-full bg-card p-2 text-sm transition-all duration-500",
|
||||
isVisible ? "translate-y-0 opacity-100" : "-translate-y-4 opacity-0",
|
||||
isDesktop ? "bg-card" : "bg-secondary-foreground",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@ -338,7 +338,7 @@ const SidebarInset = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:mb-2 md:peer-data-[variant=inset]:ml-0",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@ -737,7 +737,7 @@ const SidebarMenuSubButton = React.forwardRef<
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex min-h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
|
||||
@ -15,18 +15,22 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isIOS, isMobile } from "react-device-detect";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||
import { PolygonType } from "@/types/canvas";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
||||
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||
import UsersView from "@/views/settings/UsersView";
|
||||
@ -36,36 +40,14 @@ import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
|
||||
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useInitialCameraState } from "@/api/ws";
|
||||
import { isInIframe } from "@/utils/isIFrame";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TriggerView from "@/views/settings/TriggerView";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { LuChevronRight } from "react-icons/lu";
|
||||
import Logo from "@/components/Logo";
|
||||
import {
|
||||
MobilePage,
|
||||
MobilePageContent,
|
||||
MobilePageHeader,
|
||||
MobilePageTitle,
|
||||
} from "@/components/mobile/MobilePage";
|
||||
|
||||
const allSettingsViews = [
|
||||
"ui",
|
||||
@ -82,87 +64,11 @@ const allSettingsViews = [
|
||||
] as const;
|
||||
type SettingsType = (typeof allSettingsViews)[number];
|
||||
|
||||
const settingsGroups = [
|
||||
{
|
||||
label: "general",
|
||||
items: [{ key: "ui", component: UiSettingsView }],
|
||||
},
|
||||
{
|
||||
label: "cameras",
|
||||
items: [
|
||||
{ key: "cameras", component: CameraSettingsView },
|
||||
{ key: "masksAndZones", component: MasksAndZonesView },
|
||||
{ key: "motionTuner", component: MotionTunerView },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "enrichments",
|
||||
items: [{ key: "enrichments", component: EnrichmentsSettingsView }],
|
||||
},
|
||||
{
|
||||
label: "users",
|
||||
items: [
|
||||
{ key: "users", component: UsersView },
|
||||
{ key: "roles", component: RolesView },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "notifications",
|
||||
items: [
|
||||
{ key: "notifications", component: NotificationView },
|
||||
{ key: "triggers", component: TriggerView },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "frigateplus",
|
||||
items: [{ key: "frigateplus", component: FrigatePlusSettingsView }],
|
||||
},
|
||||
];
|
||||
|
||||
const getCurrentComponent = (page: SettingsType) => {
|
||||
for (const group of settingsGroups) {
|
||||
for (const item of group.items) {
|
||||
if (item.key === page) {
|
||||
return item.component;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function MobileMenuItem({
|
||||
item,
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
item: { key: string };
|
||||
onSelect: (key: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn("w-full justify-between pr-2", className)}
|
||||
onClick={() => {
|
||||
onSelect(item.key);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
||||
<LuChevronRight className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [page, setPage] = useState<SettingsType>("ui");
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||
const [contentMobileOpen, setContentMobileOpen] = useState(false);
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -185,8 +91,6 @@ export default function Settings() {
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
@ -240,10 +144,7 @@ export default function Settings() {
|
||||
const firstEnabledCamera =
|
||||
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
|
||||
setSelectedCamera(firstEnabledCamera.name);
|
||||
} else if (
|
||||
!cameraEnabledStates[selectedCamera] &&
|
||||
pageToggle !== "cameras"
|
||||
) {
|
||||
} else if (!cameraEnabledStates[selectedCamera] && page !== "cameras") {
|
||||
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
|
||||
const firstEnabledCamera =
|
||||
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
|
||||
@ -252,15 +153,30 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [cameras, selectedCamera, cameraEnabledStates, pageToggle]);
|
||||
}, [cameras, selectedCamera, cameraEnabledStates, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabsRef.current) {
|
||||
const element = tabsRef.current.querySelector(
|
||||
`[data-nav-item="${pageToggle}"]`,
|
||||
);
|
||||
if (element instanceof HTMLElement) {
|
||||
scrollIntoView(element, {
|
||||
behavior:
|
||||
isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth",
|
||||
inline: "start",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [tabsRef, pageToggle]);
|
||||
|
||||
useSearchEffect("page", (page: string) => {
|
||||
if (allSettingsViews.includes(page as SettingsType)) {
|
||||
// Restrict viewer to UI settings
|
||||
if (!isAdmin && !allowedViewsForViewer.includes(page as SettingsType)) {
|
||||
setPageToggle("ui");
|
||||
setPage("ui");
|
||||
} else {
|
||||
setPageToggle(page as SettingsType);
|
||||
setPage(page as SettingsType);
|
||||
}
|
||||
}
|
||||
// don't clear url params if we're creating a new object mask
|
||||
@ -277,86 +193,55 @@ export default function Settings() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentMobileOpen) {
|
||||
document.title = t("documentTitle.default");
|
||||
}
|
||||
}, [t, contentMobileOpen]);
|
||||
}, [t]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{!contentMobileOpen && (
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="sticky -top-2 z-50 mb-2 bg-background p-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Logo className="h-8" />
|
||||
</div>
|
||||
<div className="flex flex-row text-center">
|
||||
<h2 className="ml-2 text-lg font-semibold">
|
||||
{t("menu.settings", { ns: "common" })}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-container overflow-y-auto px-4">
|
||||
{settingsGroups.map((group) => {
|
||||
const filteredItems = group.items.filter((item) =>
|
||||
visibleSettingsViews.includes(item.key as SettingsType),
|
||||
);
|
||||
if (filteredItems.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label} className="mb-3">
|
||||
{filteredItems.length > 1 && (
|
||||
<h3 className="mb-2 ml-2 text-sm font-medium text-secondary-foreground">
|
||||
<div className="smart-capitalize">
|
||||
{t("menu." + group.label)}
|
||||
</div>
|
||||
</h3>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<MobileMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
className={cn(filteredItems.length == 1 && "pl-2")}
|
||||
onSelect={(key) => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!allowedViewsForViewer.includes(key as SettingsType)
|
||||
) {
|
||||
<div className="flex size-full flex-col p-2">
|
||||
<div className="relative flex h-11 w-full items-center justify-between">
|
||||
<ScrollArea className="w-full whitespace-nowrap">
|
||||
<div ref={tabsRef} className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SettingsType) => {
|
||||
if (value) {
|
||||
// Restrict viewer navigation
|
||||
if (!isAdmin && !allowedViewsForViewer.includes(value)) {
|
||||
setPageToggle("ui");
|
||||
} else {
|
||||
setPageToggle(key as SettingsType);
|
||||
setPageToggle(value);
|
||||
}
|
||||
}
|
||||
setContentMobileOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
>
|
||||
{visibleSettingsViews.map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "ui" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
data-nav-item={item}
|
||||
aria-label={t("selectItem", {
|
||||
ns: "common",
|
||||
item: t("menu." + item),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MobilePage
|
||||
open={contentMobileOpen}
|
||||
onOpenChange={setContentMobileOpen}
|
||||
>
|
||||
<MobilePageContent
|
||||
className={cn("px-2", "scrollbar-container overflow-y-auto")}
|
||||
>
|
||||
<MobilePageHeader
|
||||
className="top-0 mb-0"
|
||||
onClose={() => navigate(-1)}
|
||||
actions={
|
||||
[
|
||||
"debug",
|
||||
"cameras",
|
||||
"masksAndZones",
|
||||
"motionTuner",
|
||||
"triggers",
|
||||
].includes(pageToggle) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{pageToggle == "masksAndZones" && (
|
||||
<div className="smart-capitalize">{t("menu." + item)}</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{(page == "debug" ||
|
||||
page == "cameras" ||
|
||||
page == "masksAndZones" ||
|
||||
page == "motionTuner" ||
|
||||
page == "triggers") && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
{page == "masksAndZones" && (
|
||||
<ZoneMaskFilterButton
|
||||
selectedZoneMask={filterZoneMask}
|
||||
updateZoneMaskFilter={setFilterZoneMask}
|
||||
@ -370,27 +255,50 @@ export default function Settings() {
|
||||
currentPage={page}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<MobilePageTitle>{t("menu." + page)}</MobilePageTitle>
|
||||
</MobilePageHeader>
|
||||
|
||||
<div className="p-2">
|
||||
{(() => {
|
||||
const CurrentComponent = getCurrentComponent(page);
|
||||
if (!CurrentComponent) return null;
|
||||
return (
|
||||
<CurrentComponent
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
|
||||
{page == "ui" && <UiSettingsView />}
|
||||
{page == "enrichments" && (
|
||||
<EnrichmentsSettingsView setUnsavedChanges={setUnsavedChanges} />
|
||||
)}
|
||||
{page == "debug" && (
|
||||
<ObjectSettingsView selectedCamera={selectedCamera} />
|
||||
)}
|
||||
{page == "cameras" && (
|
||||
<CameraSettingsView
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
{page == "masksAndZones" && (
|
||||
<MasksAndZonesView
|
||||
selectedCamera={selectedCamera}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page == "motionTuner" && (
|
||||
<MotionTunerView
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page === "triggers" && (
|
||||
<TriggerView
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page == "users" && <UsersView />}
|
||||
{page == "roles" && <RolesView />}
|
||||
{page == "notifications" && (
|
||||
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
||||
)}
|
||||
{page == "frigateplus" && (
|
||||
<FrigatePlusSettingsView setUnsavedChanges={setUnsavedChanges} />
|
||||
)}
|
||||
</div>
|
||||
</MobilePageContent>
|
||||
</MobilePage>
|
||||
{confirmationDialogOpen && (
|
||||
<AlertDialog
|
||||
open={confirmationDialogOpen}
|
||||
@ -416,168 +324,6 @@ export default function Settings() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-secondary p-3">
|
||||
<Heading as="h3" className="mb-0">
|
||||
{t("menu.settings", { ns: "common" })}
|
||||
</Heading>
|
||||
{[
|
||||
"debug",
|
||||
"cameras",
|
||||
"masksAndZones",
|
||||
"motionTuner",
|
||||
"triggers",
|
||||
].includes(page) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{pageToggle == "masksAndZones" && (
|
||||
<ZoneMaskFilterButton
|
||||
selectedZoneMask={filterZoneMask}
|
||||
updateZoneMaskFilter={setFilterZoneMask}
|
||||
/>
|
||||
)}
|
||||
<CameraSelectButton
|
||||
allCameras={cameras}
|
||||
selectedCamera={selectedCamera}
|
||||
setSelectedCamera={setSelectedCamera}
|
||||
cameraEnabledStates={cameraEnabledStates}
|
||||
currentPage={page}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SidebarProvider>
|
||||
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
|
||||
<SidebarContent className="scrollbar-container mb-20 overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
|
||||
<SidebarMenu>
|
||||
{settingsGroups.map((group) => {
|
||||
const filteredItems = group.items.filter((item) =>
|
||||
visibleSettingsViews.includes(item.key as SettingsType),
|
||||
);
|
||||
if (filteredItems.length === 0) return null;
|
||||
return (
|
||||
<SidebarGroup key={group.label} className="py-1">
|
||||
{filteredItems.length === 1 ? (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="ml-0"
|
||||
isActive={pageToggle === filteredItems[0].key}
|
||||
onClick={() => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!allowedViewsForViewer.includes(
|
||||
filteredItems[0].key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("ui");
|
||||
} else {
|
||||
setPageToggle(
|
||||
filteredItems[0].key as SettingsType,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="smart-capitalize">
|
||||
{t("menu." + filteredItems[0].key)}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
) : (
|
||||
<>
|
||||
<SidebarGroupLabel
|
||||
className={cn(
|
||||
"ml-2 cursor-default pl-0 text-sm",
|
||||
filteredItems.some(
|
||||
(item) => pageToggle === item.key,
|
||||
)
|
||||
? "text-primary"
|
||||
: "text-sidebar-foreground/80",
|
||||
)}
|
||||
>
|
||||
<div className="smart-capitalize">
|
||||
{t("menu." + group.label)}
|
||||
</div>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenuSub className="mx-2 border-0">
|
||||
{filteredItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.key}>
|
||||
<SidebarMenuSubButton
|
||||
isActive={pageToggle === item.key}
|
||||
onClick={() => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!allowedViewsForViewer.includes(
|
||||
item.key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("ui");
|
||||
} else {
|
||||
setPageToggle(item.key as SettingsType);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full cursor-pointer smart-capitalize">
|
||||
{t("menu." + item.key)}
|
||||
</div>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<div className="flex-1 overflow-auto p-2 pr-0">
|
||||
{(() => {
|
||||
const CurrentComponent = getCurrentComponent(page);
|
||||
if (!CurrentComponent) return null;
|
||||
return (
|
||||
<CurrentComponent
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
{confirmationDialogOpen && (
|
||||
<AlertDialog
|
||||
open={confirmationDialogOpen}
|
||||
onOpenChange={() => setConfirmationDialogOpen(false)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("dialog.unsavedChanges.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("dialog.unsavedChanges.desc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => handleDialog(false)}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDialog(true)}>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -466,9 +466,9 @@ export function RecordingView({
|
||||
|
||||
return mainCameraReviewItems.find(
|
||||
(rev) =>
|
||||
rev.start_time - REVIEW_PADDING < currentTime &&
|
||||
rev.start_time < currentTime &&
|
||||
rev.end_time &&
|
||||
currentTime < rev.end_time + REVIEW_PADDING,
|
||||
currentTime < rev.end_time,
|
||||
);
|
||||
}, [config, currentTime, mainCameraReviewItems, mainCamera]);
|
||||
const onAnalysisOpen = useCallback(
|
||||
@ -678,12 +678,10 @@ export function RecordingView({
|
||||
: Math.max(1, getCameraAspect(mainCamera) ?? 0),
|
||||
}}
|
||||
>
|
||||
{isDesktop && (
|
||||
<GenAISummaryDialog
|
||||
review={activeReviewItem}
|
||||
onOpen={onAnalysisOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DynamicVideoPlayer
|
||||
className={grow}
|
||||
@ -775,14 +773,12 @@ export function RecordingView({
|
||||
}
|
||||
timeRange={timeRange}
|
||||
mainCameraReviewItems={mainCameraReviewItems}
|
||||
activeReviewItem={activeReviewItem}
|
||||
currentTime={currentTime}
|
||||
exportRange={exportMode == "timeline" ? exportRange : undefined}
|
||||
setCurrentTime={setCurrentTime}
|
||||
manuallySetCurrentTime={manuallySetCurrentTime}
|
||||
setScrubbing={setScrubbing}
|
||||
setExportRange={setExportRange}
|
||||
onAnalysisOpen={onAnalysisOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -796,14 +792,12 @@ type TimelineProps = {
|
||||
timelineType: TimelineType;
|
||||
timeRange: TimeRange;
|
||||
mainCameraReviewItems: ReviewSegment[];
|
||||
activeReviewItem?: ReviewSegment;
|
||||
currentTime: number;
|
||||
exportRange?: TimeRange;
|
||||
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
|
||||
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
||||
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setExportRange: (range: TimeRange) => void;
|
||||
onAnalysisOpen: (open: boolean) => void;
|
||||
};
|
||||
function Timeline({
|
||||
contentRef,
|
||||
@ -812,14 +806,12 @@ function Timeline({
|
||||
timelineType,
|
||||
timeRange,
|
||||
mainCameraReviewItems,
|
||||
activeReviewItem,
|
||||
currentTime,
|
||||
exportRange,
|
||||
setCurrentTime,
|
||||
manuallySetCurrentTime,
|
||||
setScrubbing,
|
||||
setExportRange,
|
||||
onAnalysisOpen,
|
||||
}: TimelineProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||
@ -897,17 +889,12 @@ function Timeline({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
className={`${
|
||||
isDesktop
|
||||
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} no-scrollbar overflow-y-auto`
|
||||
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : "landscape:w-[175px]"}`,
|
||||
)}
|
||||
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : "landscape:w-[175px]"} `
|
||||
} relative`}
|
||||
>
|
||||
{isMobile && (
|
||||
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
||||
{timelineType == "timeline" ? (
|
||||
@ -959,7 +946,7 @@ function Timeline({
|
||||
<ReviewCard
|
||||
key={review.id}
|
||||
event={review}
|
||||
activeReviewItem={activeReviewItem}
|
||||
currentTime={currentTime}
|
||||
onClick={() => {
|
||||
manuallySetCurrentTime(
|
||||
review.start_time - REVIEW_PADDING,
|
||||
|
||||
@ -405,9 +405,9 @@ export default function AuthenticationView({
|
||||
// Users section
|
||||
const UsersSection = (
|
||||
<>
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2 md:mr-3">
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("users.management.title")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@ -425,7 +425,7 @@ export default function AuthenticationView({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:mr-3">
|
||||
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/50">
|
||||
@ -594,9 +594,9 @@ export default function AuthenticationView({
|
||||
// Roles section
|
||||
const RolesSection = (
|
||||
<>
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2 md:mr-3">
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("roles.management.title")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@ -614,7 +614,7 @@ export default function AuthenticationView({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:mr-3">
|
||||
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/50">
|
||||
@ -784,7 +784,7 @@ export default function AuthenticationView({
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
{section === "users" && UsersSection}
|
||||
{section === "roles" && RolesSection}
|
||||
{!section && (
|
||||
|
||||
@ -313,10 +313,10 @@ export default function CameraSettingsView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
{viewMode === "settings" ? (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("camera.title")}
|
||||
</Heading>
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
|
||||
@ -244,8 +244,8 @@ export default function EnrichmentsSettingsView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("enrichments.title")}
|
||||
</Heading>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
@ -211,8 +211,8 @@ export default function FrigatePlusSettingsView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("frigatePlus.title")}
|
||||
</Heading>
|
||||
|
||||
|
||||
@ -433,7 +433,7 @@ export default function MasksAndZonesView({
|
||||
{cameraConfig && editingPolygons && (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
{editPane == "zone" && (
|
||||
<ZoneEditPane
|
||||
polygons={editingPolygons}
|
||||
@ -482,7 +482,7 @@ export default function MasksAndZonesView({
|
||||
)}
|
||||
{editPane === undefined && (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("menu.masksAndZones")}
|
||||
</Heading>
|
||||
<div className="flex w-full flex-col">
|
||||
@ -696,7 +696,7 @@ export default function MasksAndZonesView({
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex max-h-[50%] md:mr-3 md:h-dvh md:max-h-full md:w-7/12 md:grow"
|
||||
className="flex max-h-[50%] md:h-dvh md:max-h-full md:w-7/12 md:grow"
|
||||
>
|
||||
<div className="mx-auto flex size-full flex-row justify-center">
|
||||
{cameraConfig &&
|
||||
|
||||
@ -191,8 +191,8 @@ export default function MotionTunerView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("motionDetectionTuner.title")}
|
||||
</Heading>
|
||||
<div className="my-3 space-y-3 text-sm text-muted-foreground">
|
||||
@ -325,7 +325,7 @@ export default function MotionTunerView({
|
||||
</div>
|
||||
|
||||
{cameraConfig ? (
|
||||
<div className="flex max-h-[70%] md:mr-3 md:h-dvh md:max-h-full md:w-7/12 md:grow">
|
||||
<div className="flex max-h-[70%] md:h-dvh md:max-h-full md:w-7/12 md:grow">
|
||||
<div className="size-full min-h-10">
|
||||
<AutoUpdatingCameraImage
|
||||
camera={cameraConfig.name}
|
||||
|
||||
@ -331,10 +331,10 @@ export default function NotificationView({
|
||||
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
return (
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("notification.notificationSettings.title")}
|
||||
</Heading>
|
||||
<div className="max-w-6xl">
|
||||
@ -385,14 +385,14 @@ export default function NotificationView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<div
|
||||
className={cn(
|
||||
isAdmin && "grid w-full grid-cols-1 gap-4 md:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
<div className="col-span-1">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("notification.notificationSettings.title")}
|
||||
</Heading>
|
||||
|
||||
|
||||
@ -164,8 +164,8 @@ export default function ObjectSettingsView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:w-3/12">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("debug.title")}
|
||||
</Heading>
|
||||
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
|
||||
|
||||
@ -78,11 +78,7 @@ export default function TriggerView({
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const { data: trigger_status, mutate } = useSWR(
|
||||
config?.cameras[selectedCamera]?.semantic_search?.triggers &&
|
||||
Object.keys(config.cameras[selectedCamera].semantic_search.triggers)
|
||||
.length > 0
|
||||
? `/triggers/status/${selectedCamera}`
|
||||
: null,
|
||||
`/triggers/status/${selectedCamera}`,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
@ -418,11 +414,11 @@ export default function TriggerView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
{!isSemanticSearchEnabled ? (
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("triggers.management.title")}
|
||||
</Heading>
|
||||
<p className="mb-5 text-sm text-muted-foreground">
|
||||
@ -456,7 +452,7 @@ export default function TriggerView({
|
||||
<>
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("triggers.management.title")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@ -100,8 +100,8 @@ export default function UiSettingsView() {
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("general.title")}
|
||||
</Heading>
|
||||
|
||||
|
||||
@ -119,12 +119,12 @@ module.exports = {
|
||||
DEFAULT: "hsl(var(--neutral_variant))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--background))",
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
primary: "hsl(var(--primary))",
|
||||
"primary-foreground": "hsl(var(--primary-foreground))",
|
||||
accent: "hsl(var(--background-alt))",
|
||||
"accent-foreground": "hsl(var(--primary))",
|
||||
accent: "hsl(var(--primary-variant))",
|
||||
"accent-foreground": "hsl(var(--primary-foreground))",
|
||||
border: "hsl(var(--border))",
|
||||
ring: "hsl(var(--ring))",
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user