mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-31 16:12:19 +03:00
Compare commits
10
Commits
dev
...
aa0b082184
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa0b082184 | ||
|
|
7fb8d9b050 | ||
|
|
b8bc98a423 | ||
|
|
f9e06bb7b7 | ||
|
|
7cc16161b3 | ||
|
|
08311a6ee2 | ||
|
|
a08c044144 | ||
|
|
5cced22f65 | ||
|
|
b962c95725 | ||
|
|
0cbec25494 |
@@ -1,7 +1,7 @@
|
|||||||
default_target: local
|
default_target: local
|
||||||
|
|
||||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
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
|
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||||
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
BOARDS= #Initialized empty
|
BOARDS= #Initialized empty
|
||||||
|
|||||||
@@ -139,7 +139,11 @@ record:
|
|||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
|
|
||||||
When using `hwaccel_args` globally hardware encoding is used for time lapse generation. The encoder determines its own behavior so the resulting file size may be undesirably large.
|
When using `hwaccel_args`, hardware encoding is used for timelapse generation. This setting can be overridden for a specific camera (e.g., when camera resolution exceeds hardware encoder limits); set `cameras.<camera>.record.export.hwaccel_args` with the appropriate settings. Using an unrecognized value or empty string will fall back to software encoding (libx264).
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
The encoder determines its own behavior so the resulting file size may be undesirably large.
|
||||||
To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario.
|
To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|||||||
@@ -534,6 +534,8 @@ record:
|
|||||||
# The -r (framerate) dictates how smooth the output video is.
|
# The -r (framerate) dictates how smooth the output video is.
|
||||||
# So the args would be -vf setpts=0.02*PTS -r 30 in that case.
|
# So the args would be -vf setpts=0.02*PTS -r 30 in that case.
|
||||||
timelapse_args: "-vf setpts=0.04*PTS -r 30"
|
timelapse_args: "-vf setpts=0.04*PTS -r 30"
|
||||||
|
# Optional: Global hardware acceleration settings for timelapse exports. (default: inherit)
|
||||||
|
hwaccel_args: auto
|
||||||
# Optional: Recording Preview Settings
|
# Optional: Recording Preview Settings
|
||||||
preview:
|
preview:
|
||||||
# Optional: Quality of recording preview (default: shown below).
|
# Optional: Quality of recording preview (default: shown below).
|
||||||
@@ -835,6 +837,11 @@ cameras:
|
|||||||
# Optional: camera specific output args (default: inherit)
|
# Optional: camera specific output args (default: inherit)
|
||||||
# output_args:
|
# output_args:
|
||||||
|
|
||||||
|
# Optional: camera specific hwaccel args for timelapse export (default: inherit)
|
||||||
|
# record:
|
||||||
|
# export:
|
||||||
|
# hwaccel_args:
|
||||||
|
|
||||||
# Optional: timeout for highest scoring image before allowing it
|
# Optional: timeout for highest scoring image before allowing it
|
||||||
# to be replaced by a newer image. (default: shown below)
|
# to be replaced by a newer image. (default: shown below)
|
||||||
best_image_timeout: 60
|
best_image_timeout: 60
|
||||||
|
|||||||
+10
-1
@@ -32,6 +32,7 @@ from frigate.config.camera.updater import (
|
|||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateTopic,
|
CameraConfigUpdateTopic,
|
||||||
)
|
)
|
||||||
|
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||||
from frigate.util.builtin import (
|
from frigate.util.builtin import (
|
||||||
@@ -458,7 +459,15 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
|
|
||||||
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def vainfo():
|
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(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"return_code": vainfo.returncode,
|
"return_code": vainfo.returncode,
|
||||||
|
|||||||
@@ -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 import BaseModel, Field
|
||||||
from pydantic.json_schema import SkipJsonSchema
|
from pydantic.json_schema import SkipJsonSchema
|
||||||
@@ -18,3 +18,9 @@ class ExportRecordingsBody(BaseModel):
|
|||||||
)
|
)
|
||||||
name: str = Field(title="Friendly name", default=None, max_length=256)
|
name: str = Field(title="Friendly name", default=None, max_length=256)
|
||||||
image_path: Union[str, SkipJsonSchema[None]] = None
|
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(
|
in_progress: bool = Field(
|
||||||
description="Whether the export is currently being processed"
|
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):
|
class StartExportResponse(BaseModel):
|
||||||
|
|||||||
+195
-10
@@ -4,10 +4,10 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pathvalidate import sanitize_filepath
|
from pathvalidate import sanitize_filepath
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
@@ -19,8 +19,17 @@ from frigate.api.auth import (
|
|||||||
require_camera_access,
|
require_camera_access,
|
||||||
require_role,
|
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_recordings_body import ExportRecordingsBody
|
||||||
from frigate.api.defs.request.export_rename_body import ExportRenameBody
|
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 (
|
from frigate.api.defs.response.export_response import (
|
||||||
ExportModel,
|
ExportModel,
|
||||||
ExportsResponse,
|
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.response.generic_response import GenericResponse
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import CLIPS_DIR, EXPORT_DIR
|
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 (
|
from frigate.record.export import (
|
||||||
PlaybackFactorEnum,
|
PlaybackFactorEnum,
|
||||||
PlaybackSourceEnum,
|
PlaybackSourceEnum,
|
||||||
@@ -52,17 +61,182 @@ router = APIRouter(tags=[Tags.export])
|
|||||||
)
|
)
|
||||||
def get_exports(
|
def get_exports(
|
||||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
|
export_case_id: Optional[str] = None,
|
||||||
|
cameras: Optional[str] = Query(default="all"),
|
||||||
|
start_date: Optional[float] = None,
|
||||||
|
end_date: Optional[float] = None,
|
||||||
):
|
):
|
||||||
exports = (
|
query = Export.select().where(Export.camera << allowed_cameras)
|
||||||
Export.select()
|
|
||||||
.where(Export.camera << allowed_cameras)
|
if export_case_id is not None:
|
||||||
.order_by(Export.date.desc())
|
if export_case_id == "unassigned":
|
||||||
.dicts()
|
query = query.where(Export.export_case.is_null(True))
|
||||||
.iterator()
|
else:
|
||||||
)
|
query = query.where(Export.export_case == export_case_id)
|
||||||
|
|
||||||
|
if cameras and cameras != "all":
|
||||||
|
requested = set(cameras.split(","))
|
||||||
|
filtered_cameras = list(requested.intersection(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])
|
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(
|
@router.post(
|
||||||
"/export/{camera_name}/start/{start_time}/end/{end_time}",
|
"/export/{camera_name}/start/{start_time}/end/{end_time}",
|
||||||
response_model=StartExportResponse,
|
response_model=StartExportResponse,
|
||||||
@@ -93,6 +267,16 @@ def export_recording(
|
|||||||
friendly_name = body.name
|
friendly_name = body.name
|
||||||
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
|
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
|
# Ensure that existing_image is a valid path
|
||||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -161,6 +345,7 @@ def export_recording(
|
|||||||
if playback_source in PlaybackSourceEnum.__members__.values()
|
if playback_source in PlaybackSourceEnum.__members__.values()
|
||||||
else PlaybackSourceEnum.recordings
|
else PlaybackSourceEnum.recordings
|
||||||
),
|
),
|
||||||
|
export_case_id,
|
||||||
)
|
)
|
||||||
exporter.start()
|
exporter.start()
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class CameraMetrics:
|
|||||||
process_pid: Synchronized
|
process_pid: Synchronized
|
||||||
capture_process_pid: Synchronized
|
capture_process_pid: Synchronized
|
||||||
ffmpeg_pid: Synchronized
|
ffmpeg_pid: Synchronized
|
||||||
|
reconnects_last_hour: Synchronized
|
||||||
|
stalls_last_hour: Synchronized
|
||||||
|
|
||||||
def __init__(self, manager: SyncManager):
|
def __init__(self, manager: SyncManager):
|
||||||
self.camera_fps = manager.Value("d", 0)
|
self.camera_fps = manager.Value("d", 0)
|
||||||
@@ -35,6 +37,8 @@ class CameraMetrics:
|
|||||||
self.process_pid = manager.Value("i", 0)
|
self.process_pid = manager.Value("i", 0)
|
||||||
self.capture_process_pid = manager.Value("i", 0)
|
self.capture_process_pid = manager.Value("i", 0)
|
||||||
self.ffmpeg_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:
|
class PTZMetrics:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
@@ -70,6 +70,9 @@ class RecordExportConfig(FrigateBaseModel):
|
|||||||
timelapse_args: str = Field(
|
timelapse_args: str = Field(
|
||||||
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
|
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
|
||||||
)
|
)
|
||||||
|
hwaccel_args: Union[str, list[str]] = Field(
|
||||||
|
default="auto", title="Export-specific FFmpeg hardware acceleration arguments."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecordConfig(FrigateBaseModel):
|
class RecordConfig(FrigateBaseModel):
|
||||||
|
|||||||
@@ -523,6 +523,14 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
if camera_config.ffmpeg.hwaccel_args == "auto":
|
if camera_config.ffmpeg.hwaccel_args == "auto":
|
||||||
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
|
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
|
||||||
|
|
||||||
|
# Resolve export hwaccel_args: camera export -> camera ffmpeg -> global ffmpeg
|
||||||
|
# This allows per-camera override for exports (e.g., when camera resolution
|
||||||
|
# exceeds hardware encoder limits)
|
||||||
|
if camera_config.record.export.hwaccel_args == "auto":
|
||||||
|
camera_config.record.export.hwaccel_args = (
|
||||||
|
camera_config.ffmpeg.hwaccel_args
|
||||||
|
)
|
||||||
|
|
||||||
for input in camera_config.ffmpeg.inputs:
|
for input in camera_config.ffmpeg.inputs:
|
||||||
need_detect_dimensions = "detect" in input.roles and (
|
need_detect_dimensions = "detect" in input.roles and (
|
||||||
camera_config.detect.height is None
|
camera_config.detect.height is None
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ class Recordings(Model):
|
|||||||
regions = IntegerField(null=True)
|
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):
|
class Export(Model):
|
||||||
id = CharField(null=False, primary_key=True, max_length=30)
|
id = CharField(null=False, primary_key=True, max_length=30)
|
||||||
camera = CharField(index=True, max_length=20)
|
camera = CharField(index=True, max_length=20)
|
||||||
@@ -88,6 +96,12 @@ class Export(Model):
|
|||||||
video_path = CharField(unique=True)
|
video_path = CharField(unique=True)
|
||||||
thumb_path = CharField(unique=True)
|
thumb_path = CharField(unique=True)
|
||||||
in_progress = BooleanField()
|
in_progress = BooleanField()
|
||||||
|
export_case = ForeignKeyField(
|
||||||
|
ExportCase,
|
||||||
|
null=True,
|
||||||
|
backref="exports",
|
||||||
|
column_name="export_case_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReviewSegment(Model):
|
class ReviewSegment(Model):
|
||||||
|
|||||||
+18
-13
@@ -64,6 +64,7 @@ class RecordingExporter(threading.Thread):
|
|||||||
end_time: int,
|
end_time: int,
|
||||||
playback_factor: PlaybackFactorEnum,
|
playback_factor: PlaybackFactorEnum,
|
||||||
playback_source: PlaybackSourceEnum,
|
playback_source: PlaybackSourceEnum,
|
||||||
|
export_case_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -75,6 +76,7 @@ class RecordingExporter(threading.Thread):
|
|||||||
self.end_time = end_time
|
self.end_time = end_time
|
||||||
self.playback_factor = playback_factor
|
self.playback_factor = playback_factor
|
||||||
self.playback_source = playback_source
|
self.playback_source = playback_source
|
||||||
|
self.export_case_id = export_case_id
|
||||||
|
|
||||||
# ensure export thumb dir
|
# ensure export thumb dir
|
||||||
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
||||||
@@ -226,7 +228,7 @@ class RecordingExporter(threading.Thread):
|
|||||||
ffmpeg_cmd = (
|
ffmpeg_cmd = (
|
||||||
parse_preset_hardware_acceleration_encode(
|
parse_preset_hardware_acceleration_encode(
|
||||||
self.config.ffmpeg.ffmpeg_path,
|
self.config.ffmpeg.ffmpeg_path,
|
||||||
self.config.ffmpeg.hwaccel_args,
|
self.config.cameras[self.camera].record.export.hwaccel_args,
|
||||||
f"-an {ffmpeg_input}",
|
f"-an {ffmpeg_input}",
|
||||||
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart",
|
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart",
|
||||||
EncodeTypeEnum.timelapse,
|
EncodeTypeEnum.timelapse,
|
||||||
@@ -317,7 +319,7 @@ class RecordingExporter(threading.Thread):
|
|||||||
ffmpeg_cmd = (
|
ffmpeg_cmd = (
|
||||||
parse_preset_hardware_acceleration_encode(
|
parse_preset_hardware_acceleration_encode(
|
||||||
self.config.ffmpeg.ffmpeg_path,
|
self.config.ffmpeg.ffmpeg_path,
|
||||||
self.config.ffmpeg.hwaccel_args,
|
self.config.cameras[self.camera].record.export.hwaccel_args,
|
||||||
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
|
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
|
||||||
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
|
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
|
||||||
EncodeTypeEnum.timelapse,
|
EncodeTypeEnum.timelapse,
|
||||||
@@ -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"
|
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)
|
thumb_path = self.save_thumbnail(self.export_id)
|
||||||
|
|
||||||
Export.insert(
|
export_values = {
|
||||||
{
|
Export.id: self.export_id,
|
||||||
Export.id: self.export_id,
|
Export.camera: self.camera,
|
||||||
Export.camera: self.camera,
|
Export.name: export_name,
|
||||||
Export.name: export_name,
|
Export.date: self.start_time,
|
||||||
Export.date: self.start_time,
|
Export.video_path: video_path,
|
||||||
Export.video_path: video_path,
|
Export.thumb_path: thumb_path,
|
||||||
Export.thumb_path: thumb_path,
|
Export.in_progress: True,
|
||||||
Export.in_progress: True,
|
}
|
||||||
}
|
|
||||||
).execute()
|
if self.export_case_id is not None:
|
||||||
|
export_values[Export.export_case] = self.export_case_id
|
||||||
|
|
||||||
|
Export.insert(export_values).execute()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.playback_source == PlaybackSourceEnum.recordings:
|
if self.playback_source == PlaybackSourceEnum.recordings:
|
||||||
|
|||||||
+101
-13
@@ -22,6 +22,7 @@ from frigate.util.services import (
|
|||||||
get_bandwidth_stats,
|
get_bandwidth_stats,
|
||||||
get_cpu_stats,
|
get_cpu_stats,
|
||||||
get_fs_type,
|
get_fs_type,
|
||||||
|
get_hailo_temps,
|
||||||
get_intel_gpu_stats,
|
get_intel_gpu_stats,
|
||||||
get_jetson_stats,
|
get_jetson_stats,
|
||||||
get_nvidia_gpu_stats,
|
get_nvidia_gpu_stats,
|
||||||
@@ -91,9 +92,80 @@ def get_temperatures() -> dict[str, float]:
|
|||||||
if temp is not None:
|
if temp is not None:
|
||||||
temps[apex] = temp
|
temps[apex] = temp
|
||||||
|
|
||||||
|
# Get temperatures for Hailo devices
|
||||||
|
temps.update(get_hailo_temps())
|
||||||
|
|
||||||
return temps
|
return temps
|
||||||
|
|
||||||
|
|
||||||
|
def get_detector_temperature(
|
||||||
|
detector_type: str,
|
||||||
|
detector_index_by_type: dict[str, int],
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""Get temperature for a specific detector based on its type."""
|
||||||
|
if detector_type == "edgetpu":
|
||||||
|
# Get temperatures for all attached Corals
|
||||||
|
base = "/sys/class/apex/"
|
||||||
|
if os.path.isdir(base):
|
||||||
|
apex_devices = sorted(os.listdir(base))
|
||||||
|
index = detector_index_by_type.get("edgetpu", 0)
|
||||||
|
if index < len(apex_devices):
|
||||||
|
apex_name = apex_devices[index]
|
||||||
|
temp = read_temperature(os.path.join(base, apex_name, "temp"))
|
||||||
|
if temp is not None:
|
||||||
|
return temp
|
||||||
|
elif detector_type == "hailo8l":
|
||||||
|
# Get temperatures for Hailo devices
|
||||||
|
hailo_temps = get_hailo_temps()
|
||||||
|
if hailo_temps:
|
||||||
|
hailo_device_names = sorted(hailo_temps.keys())
|
||||||
|
index = detector_index_by_type.get("hailo8l", 0)
|
||||||
|
if index < len(hailo_device_names):
|
||||||
|
device_name = hailo_device_names[index]
|
||||||
|
return hailo_temps[device_name]
|
||||||
|
elif detector_type == "rknn":
|
||||||
|
# Rockchip temperatures are handled by the GPU / NPU stats
|
||||||
|
# as there are not detector specific temperatures
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_detector_stats(
|
||||||
|
stats_tracking: StatsTrackingTypes,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Get stats for all detectors, including temperatures based on detector type."""
|
||||||
|
detector_stats: dict[str, dict[str, Any]] = {}
|
||||||
|
detector_type_indices: dict[str, int] = {}
|
||||||
|
|
||||||
|
for name, detector in stats_tracking["detectors"].items():
|
||||||
|
pid = detector.detect_process.pid if detector.detect_process else None
|
||||||
|
detector_type = detector.detector_config.type
|
||||||
|
|
||||||
|
# Keep track of the index for each detector type to match temperatures correctly
|
||||||
|
current_index = detector_type_indices.get(detector_type, 0)
|
||||||
|
detector_type_indices[detector_type] = current_index + 1
|
||||||
|
|
||||||
|
detector_stat = {
|
||||||
|
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
||||||
|
# issue https://github.com/python/typeshed/issues/8799
|
||||||
|
# from mypy 0.981 onwards
|
||||||
|
"detection_start": detector.detection_start.value, # type: ignore[attr-defined]
|
||||||
|
# issue https://github.com/python/typeshed/issues/8799
|
||||||
|
# from mypy 0.981 onwards
|
||||||
|
"pid": pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
temp = get_detector_temperature(detector_type, {detector_type: current_index})
|
||||||
|
|
||||||
|
if temp is not None:
|
||||||
|
detector_stat["temperature"] = round(temp, 1)
|
||||||
|
|
||||||
|
detector_stats[name] = detector_stat
|
||||||
|
|
||||||
|
return detector_stats
|
||||||
|
|
||||||
|
|
||||||
def get_processing_stats(
|
def get_processing_stats(
|
||||||
config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str]
|
config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -174,6 +246,7 @@ async def set_gpu_stats(
|
|||||||
"mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%",
|
"mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%",
|
||||||
"enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%",
|
"enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%",
|
||||||
"dec": str(round(float(nvidia_usage[i]["dec"]), 2)) + "%",
|
"dec": str(round(float(nvidia_usage[i]["dec"]), 2)) + "%",
|
||||||
|
"temp": str(nvidia_usage[i]["temp"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -279,6 +352,32 @@ def stats_snapshot(
|
|||||||
if camera_stats.capture_process_pid.value
|
if camera_stats.capture_process_pid.value
|
||||||
else None
|
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] = {
|
stats["cameras"][name] = {
|
||||||
"camera_fps": round(camera_stats.camera_fps.value, 2),
|
"camera_fps": round(camera_stats.camera_fps.value, 2),
|
||||||
"process_fps": round(camera_stats.process_fps.value, 2),
|
"process_fps": round(camera_stats.process_fps.value, 2),
|
||||||
@@ -290,20 +389,10 @@ def stats_snapshot(
|
|||||||
"ffmpeg_pid": ffmpeg_pid,
|
"ffmpeg_pid": ffmpeg_pid,
|
||||||
"audio_rms": round(camera_stats.audio_rms.value, 4),
|
"audio_rms": round(camera_stats.audio_rms.value, 4),
|
||||||
"audio_dBFS": round(camera_stats.audio_dBFS.value, 4),
|
"audio_dBFS": round(camera_stats.audio_dBFS.value, 4),
|
||||||
|
**connection_quality,
|
||||||
}
|
}
|
||||||
|
|
||||||
stats["detectors"] = {}
|
stats["detectors"] = get_detector_stats(stats_tracking)
|
||||||
for name, detector in stats_tracking["detectors"].items():
|
|
||||||
pid = detector.detect_process.pid if detector.detect_process else None
|
|
||||||
stats["detectors"][name] = {
|
|
||||||
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
|
||||||
# issue https://github.com/python/typeshed/issues/8799
|
|
||||||
# from mypy 0.981 onwards
|
|
||||||
"detection_start": detector.detection_start.value, # type: ignore[attr-defined]
|
|
||||||
# issue https://github.com/python/typeshed/issues/8799
|
|
||||||
# from mypy 0.981 onwards
|
|
||||||
"pid": pid,
|
|
||||||
}
|
|
||||||
stats["camera_fps"] = round(total_camera_fps, 2)
|
stats["camera_fps"] = round(total_camera_fps, 2)
|
||||||
stats["process_fps"] = round(total_process_fps, 2)
|
stats["process_fps"] = round(total_process_fps, 2)
|
||||||
stats["skipped_fps"] = round(total_skipped_fps, 2)
|
stats["skipped_fps"] = round(total_skipped_fps, 2)
|
||||||
@@ -389,7 +478,6 @@ def stats_snapshot(
|
|||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"latest_version": stats_tracking["latest_frigate_version"],
|
"latest_version": stats_tracking["latest_frigate_version"],
|
||||||
"storage": {},
|
"storage": {},
|
||||||
"temperatures": get_temperatures(),
|
|
||||||
"last_updated": int(time.time()),
|
"last_updated": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+94
-12
@@ -417,12 +417,12 @@ def get_openvino_npu_stats() -> Optional[dict[str, str]]:
|
|||||||
else:
|
else:
|
||||||
usage = 0.0
|
usage = 0.0
|
||||||
|
|
||||||
return {"npu": f"{round(usage, 2)}", "mem": "-"}
|
return {"npu": f"{round(usage, 2)}", "mem": "-%"}
|
||||||
except (FileNotFoundError, PermissionError, ValueError):
|
except (FileNotFoundError, PermissionError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_rockchip_gpu_stats() -> Optional[dict[str, str]]:
|
def get_rockchip_gpu_stats() -> Optional[dict[str, str | float]]:
|
||||||
"""Get GPU stats using rk."""
|
"""Get GPU stats using rk."""
|
||||||
try:
|
try:
|
||||||
with open("/sys/kernel/debug/rkrga/load", "r") as f:
|
with open("/sys/kernel/debug/rkrga/load", "r") as f:
|
||||||
@@ -440,7 +440,16 @@ def get_rockchip_gpu_stats() -> Optional[dict[str, str]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
average_load = f"{round(sum(load_values) / len(load_values), 2)}%"
|
average_load = f"{round(sum(load_values) / len(load_values), 2)}%"
|
||||||
return {"gpu": average_load, "mem": "-"}
|
stats: dict[str, str | float] = {"gpu": average_load, "mem": "-%"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open("/sys/class/thermal/thermal_zone5/temp", "r") as f:
|
||||||
|
line = f.readline().strip()
|
||||||
|
stats["temp"] = round(int(line) / 1000, 1)
|
||||||
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]:
|
def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]:
|
||||||
@@ -463,13 +472,25 @@ def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]:
|
|||||||
|
|
||||||
percentages = [int(load) for load in core_loads]
|
percentages = [int(load) for load in core_loads]
|
||||||
mean = round(sum(percentages) / len(percentages), 2)
|
mean = round(sum(percentages) / len(percentages), 2)
|
||||||
return {"npu": mean, "mem": "-"}
|
stats: dict[str, float | str] = {"npu": mean, "mem": "-%"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open("/sys/class/thermal/thermal_zone6/temp", "r") as f:
|
||||||
|
line = f.readline().strip()
|
||||||
|
stats["temp"] = round(int(line) / 1000, 1)
|
||||||
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def try_get_info(f, h, default="N/A"):
|
def try_get_info(f, h, default="N/A", sensor=None):
|
||||||
try:
|
try:
|
||||||
if h:
|
if h:
|
||||||
v = f(h)
|
if sensor is not None:
|
||||||
|
v = f(h, sensor)
|
||||||
|
else:
|
||||||
|
v = f(h)
|
||||||
else:
|
else:
|
||||||
v = f()
|
v = f()
|
||||||
except nvml.NVMLError_NotSupported:
|
except nvml.NVMLError_NotSupported:
|
||||||
@@ -498,6 +519,9 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
|
|||||||
util = try_get_info(nvml.nvmlDeviceGetUtilizationRates, handle)
|
util = try_get_info(nvml.nvmlDeviceGetUtilizationRates, handle)
|
||||||
enc = try_get_info(nvml.nvmlDeviceGetEncoderUtilization, handle)
|
enc = try_get_info(nvml.nvmlDeviceGetEncoderUtilization, handle)
|
||||||
dec = try_get_info(nvml.nvmlDeviceGetDecoderUtilization, handle)
|
dec = try_get_info(nvml.nvmlDeviceGetDecoderUtilization, handle)
|
||||||
|
temp = try_get_info(
|
||||||
|
nvml.nvmlDeviceGetTemperature, handle, default=None, sensor=0
|
||||||
|
)
|
||||||
pstate = try_get_info(nvml.nvmlDeviceGetPowerState, handle, default=None)
|
pstate = try_get_info(nvml.nvmlDeviceGetPowerState, handle, default=None)
|
||||||
|
|
||||||
if util != "N/A":
|
if util != "N/A":
|
||||||
@@ -510,6 +534,11 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
|
|||||||
else:
|
else:
|
||||||
gpu_mem_util = -1
|
gpu_mem_util = -1
|
||||||
|
|
||||||
|
if temp != "N/A" and temp is not None:
|
||||||
|
temp = float(temp)
|
||||||
|
else:
|
||||||
|
temp = None
|
||||||
|
|
||||||
if enc != "N/A":
|
if enc != "N/A":
|
||||||
enc_util = enc[0]
|
enc_util = enc[0]
|
||||||
else:
|
else:
|
||||||
@@ -527,6 +556,7 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
|
|||||||
"enc": enc_util,
|
"enc": enc_util,
|
||||||
"dec": dec_util,
|
"dec": dec_util,
|
||||||
"pstate": pstate or "unknown",
|
"pstate": pstate or "unknown",
|
||||||
|
"temp": temp,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -549,6 +579,53 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_hailo_temps() -> dict[str, float]:
|
||||||
|
"""Get temperatures for Hailo devices."""
|
||||||
|
try:
|
||||||
|
from hailo_platform import Device
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
temps = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
device_ids = Device.scan()
|
||||||
|
for i, device_id in enumerate(device_ids):
|
||||||
|
try:
|
||||||
|
with Device(device_id) as device:
|
||||||
|
temp_info = device.control.get_chip_temperature()
|
||||||
|
|
||||||
|
# Get board name and normalise it
|
||||||
|
identity = device.control.identify()
|
||||||
|
board_name = None
|
||||||
|
for line in str(identity).split("\n"):
|
||||||
|
if line.startswith("Board Name:"):
|
||||||
|
board_name = (
|
||||||
|
line.split(":", 1)[1].strip().lower().replace("-", "")
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not board_name:
|
||||||
|
board_name = f"hailo{i}"
|
||||||
|
|
||||||
|
# Use indexed name if multiple devices, otherwise just the board name
|
||||||
|
device_name = (
|
||||||
|
f"{board_name}-{i}" if len(device_ids) > 1 else board_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# ts1_temperature is also available, but appeared to be the same as ts0 in testing.
|
||||||
|
temps[device_name] = round(temp_info.ts0_temperature, 1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
f"Failed to get temperature for Hailo device {device_id}: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to scan for Hailo devices: {e}")
|
||||||
|
|
||||||
|
return temps
|
||||||
|
|
||||||
|
|
||||||
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
||||||
"""Run ffprobe on stream."""
|
"""Run ffprobe on stream."""
|
||||||
clean_path = escape_special_characters(path)
|
clean_path = escape_special_characters(path)
|
||||||
@@ -584,12 +661,17 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
|
|||||||
|
|
||||||
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
|
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
|
||||||
"""Run vainfo."""
|
"""Run vainfo."""
|
||||||
ffprobe_cmd = (
|
if not device_name:
|
||||||
["vainfo"]
|
cmd = ["vainfo"]
|
||||||
if not device_name
|
else:
|
||||||
else ["vainfo", "--display", "drm", "--device", f"/dev/dri/{device_name}"]
|
if os.path.isabs(device_name) and device_name.startswith("/dev/dri/"):
|
||||||
)
|
device_path = device_name
|
||||||
return sp.run(ffprobe_cmd, capture_output=True)
|
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]:
|
def get_nvidia_driver_info() -> dict[str, Any]:
|
||||||
|
|||||||
+102
-19
@@ -3,6 +3,7 @@ import queue
|
|||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from multiprocessing import Queue, Value
|
from multiprocessing import Queue, Value
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
@@ -115,6 +116,7 @@ def capture_frames(
|
|||||||
frame_rate.start()
|
frame_rate.start()
|
||||||
skipped_eps = EventsPerSecond()
|
skipped_eps = EventsPerSecond()
|
||||||
skipped_eps.start()
|
skipped_eps.start()
|
||||||
|
|
||||||
config_subscriber = CameraConfigUpdateSubscriber(
|
config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
None, {config.name: config}, [CameraConfigUpdateEnum.enabled]
|
None, {config.name: config}, [CameraConfigUpdateEnum.enabled]
|
||||||
)
|
)
|
||||||
@@ -179,6 +181,9 @@ class CameraWatchdog(threading.Thread):
|
|||||||
camera_fps,
|
camera_fps,
|
||||||
skipped_fps,
|
skipped_fps,
|
||||||
ffmpeg_pid,
|
ffmpeg_pid,
|
||||||
|
stalls,
|
||||||
|
reconnects,
|
||||||
|
detection_frame,
|
||||||
stop_event,
|
stop_event,
|
||||||
):
|
):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
@@ -199,6 +204,10 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.frame_index = 0
|
self.frame_index = 0
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.sleeptime = self.config.ffmpeg.retry_interval
|
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(
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
None,
|
None,
|
||||||
@@ -213,6 +222,35 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.latest_invalid_segment_time: float = 0
|
self.latest_invalid_segment_time: float = 0
|
||||||
self.latest_cache_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:
|
def _update_enabled_state(self) -> bool:
|
||||||
"""Fetch the latest config and update enabled state."""
|
"""Fetch the latest config and update enabled state."""
|
||||||
self.config_subscriber.check_for_updates()
|
self.config_subscriber.check_for_updates()
|
||||||
@@ -239,6 +277,14 @@ class CameraWatchdog(threading.Thread):
|
|||||||
else:
|
else:
|
||||||
self.ffmpeg_detect_process.wait()
|
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
|
# 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():
|
if self.capture_thread is not None and self.capture_thread.is_alive():
|
||||||
self.logger.info("Waiting for capture thread to exit...")
|
self.logger.info("Waiting for capture thread to exit...")
|
||||||
@@ -261,7 +307,10 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.start_all_ffmpeg()
|
self.start_all_ffmpeg()
|
||||||
|
|
||||||
time.sleep(self.sleeptime)
|
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()
|
enabled = self._update_enabled_state()
|
||||||
if enabled != self.was_enabled:
|
if enabled != self.was_enabled:
|
||||||
if enabled:
|
if enabled:
|
||||||
@@ -277,12 +326,9 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.stop_all_ffmpeg()
|
self.stop_all_ffmpeg()
|
||||||
|
|
||||||
# update camera status
|
# update camera status
|
||||||
self.requestor.send_data(
|
now = datetime.now().timestamp()
|
||||||
f"{self.config.name}/status/detect", "disabled"
|
self._send_detect_status("disabled", now)
|
||||||
)
|
self._send_record_status("disabled", now)
|
||||||
self.requestor.send_data(
|
|
||||||
f"{self.config.name}/status/record", "disabled"
|
|
||||||
)
|
|
||||||
self.was_enabled = enabled
|
self.was_enabled = enabled
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -321,36 +367,44 @@ class CameraWatchdog(threading.Thread):
|
|||||||
|
|
||||||
now = datetime.now().timestamp()
|
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():
|
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.camera_fps.value = 0
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
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):
|
elif self.camera_fps.value >= (self.config.detect.fps + 10):
|
||||||
self.fps_overflow_count += 1
|
self.fps_overflow_count += 1
|
||||||
|
|
||||||
if self.fps_overflow_count == 3:
|
if self.fps_overflow_count == 3:
|
||||||
self.requestor.send_data(
|
self._send_detect_status("offline", now)
|
||||||
f"{self.config.name}/status/detect", "offline"
|
|
||||||
)
|
|
||||||
self.fps_overflow_count = 0
|
self.fps_overflow_count = 0
|
||||||
self.camera_fps.value = 0
|
self.camera_fps.value = 0
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"{self.config.name} exceeded fps limit. Exiting ffmpeg..."
|
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:
|
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.camera_fps.value = 0
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
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:
|
else:
|
||||||
# process is running normally
|
# 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
|
self.fps_overflow_count = 0
|
||||||
|
|
||||||
for p in self.ffmpeg_other_processes:
|
for p in self.ffmpeg_other_processes:
|
||||||
@@ -421,9 +475,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
|
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self.requestor.send_data(
|
self._send_record_status("online", now)
|
||||||
f"{self.config.name}/status/record", "online"
|
|
||||||
)
|
|
||||||
p["latest_segment_time"] = self.latest_cache_segment_time
|
p["latest_segment_time"] = self.latest_cache_segment_time
|
||||||
|
|
||||||
if poll is None:
|
if poll is None:
|
||||||
@@ -439,6 +491,34 @@ class CameraWatchdog(threading.Thread):
|
|||||||
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
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.stop_all_ffmpeg()
|
||||||
self.logpipe.close()
|
self.logpipe.close()
|
||||||
self.config_subscriber.stop()
|
self.config_subscriber.stop()
|
||||||
@@ -576,6 +656,9 @@ class CameraCapture(FrigateProcess):
|
|||||||
self.camera_metrics.camera_fps,
|
self.camera_metrics.camera_fps,
|
||||||
self.camera_metrics.skipped_fps,
|
self.camera_metrics.skipped_fps,
|
||||||
self.camera_metrics.ffmpeg_pid,
|
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,
|
self.stop_event,
|
||||||
)
|
)
|
||||||
camera_watchdog.start()
|
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
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
"documentTitle": "Export - Frigate",
|
"documentTitle": "Export - Frigate",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"noExports": "No exports found",
|
"noExports": "No exports found",
|
||||||
|
"headings": {
|
||||||
|
"cases": "Cases",
|
||||||
|
"uncategorizedExports": "Uncategorized Exports"
|
||||||
|
},
|
||||||
"deleteExport": "Delete Export",
|
"deleteExport": "Delete Export",
|
||||||
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
|
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
|
||||||
"editExport": {
|
"editExport": {
|
||||||
@@ -13,11 +17,21 @@
|
|||||||
"shareExport": "Share export",
|
"shareExport": "Share export",
|
||||||
"downloadVideo": "Download video",
|
"downloadVideo": "Download video",
|
||||||
"editName": "Edit name",
|
"editName": "Edit name",
|
||||||
"deleteExport": "Delete export"
|
"deleteExport": "Delete export",
|
||||||
|
"assignToCase": "Add to case"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"error": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"gpuMemory": "GPU Memory",
|
"gpuMemory": "GPU Memory",
|
||||||
"gpuEncoder": "GPU Encoder",
|
"gpuEncoder": "GPU Encoder",
|
||||||
"gpuDecoder": "GPU Decoder",
|
"gpuDecoder": "GPU Decoder",
|
||||||
|
"gpuTemperature": "GPU Temperature",
|
||||||
"gpuInfo": {
|
"gpuInfo": {
|
||||||
"vainfoOutput": {
|
"vainfoOutput": {
|
||||||
"title": "Vainfo Output",
|
"title": "Vainfo Output",
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
},
|
},
|
||||||
"npuUsage": "NPU Usage",
|
"npuUsage": "NPU Usage",
|
||||||
"npuMemory": "NPU Memory",
|
"npuMemory": "NPU Memory",
|
||||||
|
"npuTemperature": "NPU Temperature",
|
||||||
"intelGpuWarning": {
|
"intelGpuWarning": {
|
||||||
"title": "Intel GPU Stats Warning",
|
"title": "Intel GPU Stats Warning",
|
||||||
"message": "GPU stats unavailable",
|
"message": "GPU stats unavailable",
|
||||||
@@ -151,6 +153,17 @@
|
|||||||
"cameraDetectionsPerSecond": "{{camName}} detections per second",
|
"cameraDetectionsPerSecond": "{{camName}} detections per second",
|
||||||
"cameraSkippedDetectionsPerSecond": "{{camName}} skipped 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": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"copyToClipboard": "Copied probe data to clipboard."
|
"copyToClipboard": "Copied probe data to clipboard."
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { LuTrash } from "react-icons/lu";
|
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -14,35 +13,62 @@ import {
|
|||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { DeleteClipType, Export } from "@/types/export";
|
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
||||||
import { MdEditSquare } from "react-icons/md";
|
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { shareOrCopy } from "@/utils/browserUtil";
|
import { shareOrCopy } from "@/utils/browserUtil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||||
import BlurredIconButton from "../button/BlurredIconButton";
|
import BlurredIconButton from "../button/BlurredIconButton";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
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;
|
className: string;
|
||||||
exportedRecording: Export;
|
exportedRecording: Export;
|
||||||
onSelect: (selected: Export) => void;
|
onSelect: (selected: Export) => void;
|
||||||
onRename: (original: string, update: string) => void;
|
onRename: (original: string, update: string) => void;
|
||||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||||
|
onAssignToCase?: (selected: Export) => void;
|
||||||
};
|
};
|
||||||
|
export function ExportCard({
|
||||||
export default function ExportCard({
|
|
||||||
className,
|
className,
|
||||||
exportedRecording,
|
exportedRecording,
|
||||||
onSelect,
|
onSelect,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ExportProps) {
|
onAssignToCase,
|
||||||
|
}: ExportCardProps) {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(
|
const [loading, setLoading] = useState(
|
||||||
exportedRecording.thumb_path.length > 0,
|
exportedRecording.thumb_path.length > 0,
|
||||||
);
|
);
|
||||||
@@ -136,12 +162,14 @@ export default function ExportCard({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
|
onClick={() => {
|
||||||
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
|
if (!exportedRecording.in_progress) {
|
||||||
onClick={isDesktop ? undefined : () => setHovered(!hovered)}
|
onSelect(exportedRecording);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{exportedRecording.in_progress ? (
|
{exportedRecording.in_progress ? (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
@@ -158,95 +186,88 @@ export default function ExportCard({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hovered && (
|
{!exportedRecording.in_progress && (
|
||||||
<>
|
<div className="absolute bottom-2 right-3 z-40">
|
||||||
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
|
<DropdownMenu modal={false}>
|
||||||
<div className="absolute right-3 top-2">
|
<DropdownMenuTrigger>
|
||||||
<div className="flex items-center justify-center gap-4">
|
<BlurredIconButton
|
||||||
{!exportedRecording.in_progress && (
|
aria-label={t("tooltip.editName")}
|
||||||
<Tooltip>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<TooltipTrigger asChild>
|
>
|
||||||
<BlurredIconButton
|
<FiMoreVertical className="size-5" />
|
||||||
onClick={() =>
|
</BlurredIconButton>
|
||||||
shareOrCopy(
|
</DropdownMenuTrigger>
|
||||||
`${baseUrl}export?id=${exportedRecording.id}`,
|
<DropdownMenuContent align="end">
|
||||||
exportedRecording.name.replaceAll("_", " "),
|
<DropdownMenuItem
|
||||||
)
|
className="cursor-pointer"
|
||||||
}
|
aria-label={t("tooltip.shareExport")}
|
||||||
>
|
onClick={(e) => {
|
||||||
<FaShareAlt className="size-4" />
|
e.stopPropagation();
|
||||||
</BlurredIconButton>
|
shareOrCopy(
|
||||||
</TooltipTrigger>
|
`${baseUrl}export?id=${exportedRecording.id}`,
|
||||||
<TooltipContent>{t("tooltip.shareExport")}</TooltipContent>
|
exportedRecording.name.replaceAll("_", " "),
|
||||||
</Tooltip>
|
);
|
||||||
)}
|
}}
|
||||||
{!exportedRecording.in_progress && (
|
>
|
||||||
|
{t("tooltip.shareExport")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label={t("tooltip.downloadVideo")}
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
download
|
download
|
||||||
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
|
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
{t("tooltip.downloadVideo")}
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<BlurredIconButton>
|
|
||||||
<FaDownload className="size-4" />
|
|
||||||
</BlurredIconButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("tooltip.downloadVideo")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
{isAdmin && !exportedRecording.in_progress && (
|
{isAdmin && onAssignToCase && (
|
||||||
<Tooltip>
|
<DropdownMenuItem
|
||||||
<TooltipTrigger asChild>
|
className="cursor-pointer"
|
||||||
<BlurredIconButton
|
aria-label={t("tooltip.assignToCase")}
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
setEditName({
|
e.stopPropagation();
|
||||||
original: exportedRecording.name,
|
onAssignToCase(exportedRecording);
|
||||||
update: undefined,
|
}}
|
||||||
})
|
>
|
||||||
}
|
{t("tooltip.assignToCase")}
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<MdEditSquare className="size-4" />
|
|
||||||
</BlurredIconButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Tooltip>
|
<DropdownMenuItem
|
||||||
<TooltipTrigger asChild>
|
className="cursor-pointer"
|
||||||
<BlurredIconButton
|
aria-label={t("tooltip.editName")}
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
onDelete({
|
e.stopPropagation();
|
||||||
file: exportedRecording.id,
|
setEditName({
|
||||||
exportName: exportedRecording.name,
|
original: exportedRecording.name,
|
||||||
})
|
update: undefined,
|
||||||
}
|
});
|
||||||
>
|
}}
|
||||||
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
|
>
|
||||||
</BlurredIconButton>
|
{t("tooltip.editName")}
|
||||||
</TooltipTrigger>
|
</DropdownMenuItem>
|
||||||
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{isAdmin && (
|
||||||
</div>
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
{!exportedRecording.in_progress && (
|
aria-label={t("tooltip.deleteExport")}
|
||||||
<Button
|
onClick={(e) => {
|
||||||
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"
|
e.stopPropagation();
|
||||||
aria-label={t("button.play", { ns: "common" })}
|
onDelete({
|
||||||
variant="ghost"
|
file: exportedRecording.id,
|
||||||
onClick={() => {
|
exportName: exportedRecording.name,
|
||||||
onSelect(exportedRecording);
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaPlay />
|
{t("tooltip.deleteExport")}
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</>
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DEFAULT_EXPORT_FILTERS,
|
||||||
|
ExportFilter,
|
||||||
|
ExportFilters,
|
||||||
|
} from "@/types/export";
|
||||||
|
import { CamerasFilterButton } from "./CamerasFilterButton";
|
||||||
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
type ExportFilterGroupProps = {
|
||||||
|
className: string;
|
||||||
|
filters?: ExportFilters[];
|
||||||
|
filter?: ExportFilter;
|
||||||
|
onUpdateFilter: (filter: ExportFilter) => void;
|
||||||
|
};
|
||||||
|
export default function ExportFilterGroup({
|
||||||
|
className,
|
||||||
|
filter,
|
||||||
|
filters = DEFAULT_EXPORT_FILTERS,
|
||||||
|
onUpdateFilter,
|
||||||
|
}: ExportFilterGroupProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
const allowedCameras = useAllowedCameras();
|
||||||
|
|
||||||
|
const filterValues = useMemo(
|
||||||
|
() => ({
|
||||||
|
cameras: allowedCameras,
|
||||||
|
}),
|
||||||
|
[allowedCameras],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(config.camera_groups).sort(
|
||||||
|
(a, b) => a[1].order - b[1].order,
|
||||||
|
);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"scrollbar-container flex justify-center gap-2 overflow-x-auto",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filters.includes("cameras") && (
|
||||||
|
<CamerasFilterButton
|
||||||
|
allCameras={filterValues.cameras}
|
||||||
|
groups={groups}
|
||||||
|
selectedCameras={filter?.cameras}
|
||||||
|
hideText={false}
|
||||||
|
updateCameraFilter={(newCameras) => {
|
||||||
|
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+463
-55
@@ -1,5 +1,5 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import ExportCard from "@/components/card/ExportCard";
|
import { CaseCard, ExportCard } from "@/components/card/ExportCard";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
@@ -11,64 +11,144 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import Heading from "@/components/ui/heading";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
|
import { useHistoryBack } from "@/hooks/use-history-back";
|
||||||
|
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DeleteClipType, Export } from "@/types/export";
|
import {
|
||||||
|
DeleteClipType,
|
||||||
|
Export,
|
||||||
|
ExportCase,
|
||||||
|
ExportFilter,
|
||||||
|
} from "@/types/export";
|
||||||
|
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
import { isMobile } from "react-device-detect";
|
MutableRefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { isMobile, isMobileOnly } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LuFolderX } from "react-icons/lu";
|
import { LuFolderX } from "react-icons/lu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
|
||||||
|
|
||||||
|
// always parse these as string arrays
|
||||||
|
const EXPORT_FILTER_ARRAY_KEYS = ["cameras"];
|
||||||
|
|
||||||
function Exports() {
|
function Exports() {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const { data: exports, mutate } = useSWR<Export[]>("exports");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle");
|
document.title = t("documentTitle");
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
|
||||||
|
const [exportFilter, setExportFilter, exportSearchParams] =
|
||||||
|
useApiFilterArgs<ExportFilter>(EXPORT_FILTER_ARRAY_KEYS);
|
||||||
|
|
||||||
|
// Data
|
||||||
|
|
||||||
|
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
|
||||||
|
const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
|
||||||
|
exportSearchParams && Object.keys(exportSearchParams).length > 0
|
||||||
|
? ["exports", exportSearchParams]
|
||||||
|
: "exports",
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
|
||||||
|
const grouped: { [caseId: string]: Export[] } = {};
|
||||||
|
(rawExports ?? []).forEach((exp) => {
|
||||||
|
const caseId = exp.export_case || "none";
|
||||||
|
if (!grouped[caseId]) {
|
||||||
|
grouped[caseId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped[caseId].push(exp);
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}, [rawExports]);
|
||||||
|
|
||||||
|
const filteredCases = useMemo<ExportCase[]>(() => {
|
||||||
|
if (!cases) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return cases.filter((caseItem) => {
|
||||||
|
const caseExports = exportsByCase[caseItem.id];
|
||||||
|
return caseExports?.length;
|
||||||
|
});
|
||||||
|
}, [cases, exportsByCase]);
|
||||||
|
|
||||||
|
const exports = useMemo<Export[]>(
|
||||||
|
() => exportsByCase["none"] || [],
|
||||||
|
[exportsByCase],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutate = useCallback(() => {
|
||||||
|
updateExports();
|
||||||
|
updateCases();
|
||||||
|
}, [updateExports, updateCases]);
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
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
|
// Viewing
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Export>();
|
const [selected, setSelected] = useState<Export>();
|
||||||
|
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
||||||
|
|
||||||
|
// Handle browser back button to deselect case before navigating away
|
||||||
|
useHistoryBack({
|
||||||
|
enabled: true,
|
||||||
|
open: selectedCaseId !== undefined,
|
||||||
|
onClose: () => setSelectedCaseId(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
useSearchEffect("id", (id) => {
|
useSearchEffect("id", (id) => {
|
||||||
if (!exports) {
|
if (!rawExports) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelected(exports.find((exp) => exp.id == id));
|
setSelected(rawExports.find((exp) => exp.id == id));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deleting
|
useSearchEffect("caseId", (caseId: string) => {
|
||||||
|
if (!filteredCases) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = filteredCases.some((c) => c.id === caseId);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCaseId(caseId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modifying
|
||||||
|
|
||||||
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
||||||
|
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
||||||
|
|
||||||
const onHandleDelete = useCallback(() => {
|
const onHandleDelete = useCallback(() => {
|
||||||
if (!deleteClip) {
|
if (!deleteClip) {
|
||||||
@@ -83,8 +163,6 @@ function Exports() {
|
|||||||
});
|
});
|
||||||
}, [deleteClip, mutate]);
|
}, [deleteClip, mutate]);
|
||||||
|
|
||||||
// Renaming
|
|
||||||
|
|
||||||
const onHandleRename = useCallback(
|
const onHandleRename = useCallback(
|
||||||
(id: string, update: string) => {
|
(id: string, update: string) => {
|
||||||
axios
|
axios
|
||||||
@@ -107,7 +185,7 @@ function Exports() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mutate, t],
|
[mutate, setDeleteClip, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard Listener
|
// Keyboard Listener
|
||||||
@@ -115,10 +193,27 @@ function Exports() {
|
|||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
useKeyboardListener([], undefined, contentRef);
|
useKeyboardListener([], undefined, contentRef);
|
||||||
|
|
||||||
|
const selectedCase = useMemo(
|
||||||
|
() => filteredCases?.find((c) => c.id === selectedCaseId),
|
||||||
|
[filteredCases, selectedCaseId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetCaseDialog = useCallback(() => {
|
||||||
|
setExportToAssign(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
||||||
<Toaster closeButton={true} />
|
<Toaster closeButton={true} />
|
||||||
|
|
||||||
|
<CaseAssignmentDialog
|
||||||
|
exportToAssign={exportToAssign}
|
||||||
|
cases={cases}
|
||||||
|
selectedCaseId={selectedCaseId}
|
||||||
|
onClose={resetCaseDialog}
|
||||||
|
mutate={mutate}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteClip != undefined}
|
open={deleteClip != undefined}
|
||||||
onOpenChange={() => setDeleteClip(undefined)}
|
onOpenChange={() => setDeleteClip(undefined)}
|
||||||
@@ -187,47 +282,360 @@ function Exports() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{exports && (
|
<div
|
||||||
<div className="flex w-full items-center justify-center p-2">
|
className={cn(
|
||||||
|
"flex w-full flex-col items-start space-y-2 pr-2 md:mb-2 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
|
||||||
|
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
<Input
|
<Input
|
||||||
className="text-md w-full bg-muted md:w-1/3"
|
className="text-md w-full bg-muted md:w-1/2"
|
||||||
placeholder={t("search")}
|
placeholder={t("search")}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<ExportFilterGroup
|
||||||
|
className="w-full justify-between md:justify-start lg:justify-end"
|
||||||
|
filter={exportFilter}
|
||||||
|
filters={["cameras"]}
|
||||||
|
onUpdateFilter={setExportFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-full overflow-hidden">
|
{selectedCase ? (
|
||||||
{exports && filteredExports && filteredExports.length > 0 ? (
|
<CaseView
|
||||||
<div
|
contentRef={contentRef}
|
||||||
ref={contentRef}
|
selectedCase={selectedCase}
|
||||||
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
exports={exportsByCase[selectedCase.id] || []}
|
||||||
>
|
search={search}
|
||||||
{Object.values(exports).map((item) => (
|
setSelected={setSelected}
|
||||||
<ExportCard
|
renameClip={onHandleRename}
|
||||||
key={item.name}
|
setDeleteClip={setDeleteClip}
|
||||||
className={
|
onAssignToCase={setExportToAssign}
|
||||||
search == "" || filteredExports.includes(item) ? "" : "hidden"
|
/>
|
||||||
}
|
) : (
|
||||||
exportedRecording={item}
|
<AllExportsView
|
||||||
onSelect={setSelected}
|
contentRef={contentRef}
|
||||||
onRename={onHandleRename}
|
search={search}
|
||||||
onDelete={({ file, exportName }) =>
|
cases={filteredCases}
|
||||||
setDeleteClip({ file, exportName })
|
exports={exports}
|
||||||
}
|
setSelectedCaseId={setSelectedCaseId}
|
||||||
/>
|
setSelected={setSelected}
|
||||||
))}
|
renameClip={onHandleRename}
|
||||||
</div>
|
setDeleteClip={setDeleteClip}
|
||||||
) : (
|
onAssignToCase={setExportToAssign}
|
||||||
<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 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 overflow-hidden">
|
||||||
|
<div className="flex shrink-0 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 min-h-0 flex-1 content-start 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>
|
||||||
</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;
|
export default Exports;
|
||||||
|
|||||||
@@ -6,9 +6,28 @@ export type Export = {
|
|||||||
video_path: string;
|
video_path: string;
|
||||||
thumb_path: string;
|
thumb_path: string;
|
||||||
in_progress: boolean;
|
in_progress: boolean;
|
||||||
|
export_case?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportCase = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeleteClipType = {
|
export type DeleteClipType = {
|
||||||
file: string;
|
file: string;
|
||||||
exportName: string;
|
exportName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// filtering
|
||||||
|
|
||||||
|
const EXPORT_FILTERS = ["cameras"] as const;
|
||||||
|
export type ExportFilters = (typeof EXPORT_FILTERS)[number];
|
||||||
|
export const DEFAULT_EXPORT_FILTERS: ExportFilters[] = ["cameras"];
|
||||||
|
|
||||||
|
export type ExportFilter = {
|
||||||
|
cameras?: string[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export type CameraStats = {
|
|||||||
pid: number;
|
pid: number;
|
||||||
process_fps: number;
|
process_fps: number;
|
||||||
skipped_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 = {
|
export type CpuStats = {
|
||||||
@@ -37,6 +41,7 @@ export type DetectorStats = {
|
|||||||
detection_start: number;
|
detection_start: number;
|
||||||
inference_speed: number;
|
inference_speed: number;
|
||||||
pid: number;
|
pid: number;
|
||||||
|
temperature?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmbeddingsStats = {
|
export type EmbeddingsStats = {
|
||||||
@@ -56,11 +61,13 @@ export type GpuStats = {
|
|||||||
enc?: string;
|
enc?: string;
|
||||||
dec?: string;
|
dec?: string;
|
||||||
pstate?: string;
|
pstate?: string;
|
||||||
|
temp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NpuStats = {
|
export type NpuStats = {
|
||||||
npu: number;
|
npu: number;
|
||||||
mem: string;
|
mem: string;
|
||||||
|
temp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GpuInfo = "vainfo" | "nvinfo";
|
export type GpuInfo = "vainfo" | "nvinfo";
|
||||||
@@ -68,7 +75,6 @@ export type GpuInfo = "vainfo" | "nvinfo";
|
|||||||
export type ServiceStats = {
|
export type ServiceStats = {
|
||||||
last_updated: number;
|
last_updated: number;
|
||||||
storage: { [path: string]: StorageStats };
|
storage: { [path: string]: StorageStats };
|
||||||
temperatures: { [apex: string]: number };
|
|
||||||
uptime: number;
|
uptime: number;
|
||||||
latest_version: string;
|
latest_version: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useFrigateStats } from "@/api/ws";
|
import { useFrigateStats } from "@/api/ws";
|
||||||
import { CameraLineGraph } from "@/components/graph/LineGraph";
|
import { CameraLineGraph } from "@/components/graph/LineGraph";
|
||||||
import CameraInfoDialog from "@/components/overlay/CameraInfoDialog";
|
import CameraInfoDialog from "@/components/overlay/CameraInfoDialog";
|
||||||
|
import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { FrigateStats } from "@/types/stats";
|
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 w-full flex-col gap-3">
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div className="text-sm font-medium text-muted-foreground smart-capitalize">
|
<div className="flex items-center gap-2">
|
||||||
<CameraNameLabel camera={camera} />
|
<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>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
|
|||||||
@@ -127,13 +127,6 @@ export default function GeneralMetrics({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
statsHistory.length > 0 &&
|
|
||||||
Object.keys(statsHistory[0].service.temperatures).length == 0
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const series: {
|
const series: {
|
||||||
[key: string]: { name: string; data: { x: number; y: number }[] };
|
[key: string]: { name: string; data: { x: number; y: number }[] };
|
||||||
} = {};
|
} = {};
|
||||||
@@ -143,22 +136,22 @@ export default function GeneralMetrics({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(stats.detectors).forEach(([key], cIdx) => {
|
Object.entries(stats.detectors).forEach(([key, detectorStats]) => {
|
||||||
if (!key.includes("coral")) {
|
if (detectorStats.temperature === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cIdx <= Object.keys(stats.service.temperatures).length) {
|
if (!(key in series)) {
|
||||||
if (!(key in series)) {
|
series[key] = {
|
||||||
series[key] = {
|
name: key,
|
||||||
name: key,
|
data: [],
|
||||||
data: [],
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const temp = Object.values(stats.service.temperatures)[cIdx];
|
|
||||||
series[key].data.push({ x: statsIdx + 1, y: Math.round(temp) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
series[key].data.push({
|
||||||
|
x: statsIdx + 1,
|
||||||
|
y: Math.round(detectorStats.temperature),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,6 +368,40 @@ export default function GeneralMetrics({
|
|||||||
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
||||||
}, [statsHistory]);
|
}, [statsHistory]);
|
||||||
|
|
||||||
|
const gpuTempSeries = useMemo(() => {
|
||||||
|
if (!statsHistory) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const series: {
|
||||||
|
[key: string]: { name: string; data: { x: number; y: number }[] };
|
||||||
|
} = {};
|
||||||
|
let hasValidGpu = false;
|
||||||
|
|
||||||
|
statsHistory.forEach((stats, statsIdx) => {
|
||||||
|
if (!stats) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(stats.gpu_usages || {}).forEach(([key, stats]) => {
|
||||||
|
if (!(key in series)) {
|
||||||
|
series[key] = { name: key, data: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.temp !== undefined) {
|
||||||
|
hasValidGpu = true;
|
||||||
|
series[key].data.push({ x: statsIdx + 1, y: stats.temp });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasValidGpu) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
||||||
|
}, [statsHistory]);
|
||||||
|
|
||||||
// Check if Intel GPU has all 0% usage values (known bug)
|
// Check if Intel GPU has all 0% usage values (known bug)
|
||||||
const showIntelGpuWarning = useMemo(() => {
|
const showIntelGpuWarning = useMemo(() => {
|
||||||
if (!statsHistory || statsHistory.length < 3) {
|
if (!statsHistory || statsHistory.length < 3) {
|
||||||
@@ -455,6 +482,40 @@ export default function GeneralMetrics({
|
|||||||
return Object.keys(series).length > 0 ? Object.values(series) : [];
|
return Object.keys(series).length > 0 ? Object.values(series) : [];
|
||||||
}, [statsHistory]);
|
}, [statsHistory]);
|
||||||
|
|
||||||
|
const npuTempSeries = useMemo(() => {
|
||||||
|
if (!statsHistory) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const series: {
|
||||||
|
[key: string]: { name: string; data: { x: number; y: number }[] };
|
||||||
|
} = {};
|
||||||
|
let hasValidNpu = false;
|
||||||
|
|
||||||
|
statsHistory.forEach((stats, statsIdx) => {
|
||||||
|
if (!stats) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(stats.npu_usages || {}).forEach(([key, stats]) => {
|
||||||
|
if (!(key in series)) {
|
||||||
|
series[key] = { name: key, data: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.temp !== undefined) {
|
||||||
|
hasValidNpu = true;
|
||||||
|
series[key].data.push({ x: statsIdx + 1, y: stats.temp });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasValidNpu) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
||||||
|
}, [statsHistory]);
|
||||||
|
|
||||||
// other processes stats
|
// other processes stats
|
||||||
|
|
||||||
const hardwareType = useMemo(() => {
|
const hardwareType = useMemo(() => {
|
||||||
@@ -676,7 +737,11 @@ export default function GeneralMetrics({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2",
|
"mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2",
|
||||||
gpuEncSeries?.length && "md:grid-cols-4",
|
gpuTempSeries?.length && "md:grid-cols-3",
|
||||||
|
gpuEncSeries?.length && "xl:grid-cols-4",
|
||||||
|
gpuEncSeries?.length &&
|
||||||
|
gpuTempSeries?.length &&
|
||||||
|
"3xl:grid-cols-5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{statsHistory[0]?.gpu_usages && (
|
{statsHistory[0]?.gpu_usages && (
|
||||||
@@ -811,6 +876,30 @@ export default function GeneralMetrics({
|
|||||||
) : (
|
) : (
|
||||||
<Skeleton className="aspect-video w-full" />
|
<Skeleton className="aspect-video w-full" />
|
||||||
)}
|
)}
|
||||||
|
{statsHistory.length != 0 ? (
|
||||||
|
<>
|
||||||
|
{gpuTempSeries && gpuTempSeries?.length != 0 && (
|
||||||
|
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||||
|
<div className="mb-5">
|
||||||
|
{t("general.hardwareInfo.gpuTemperature")}
|
||||||
|
</div>
|
||||||
|
{gpuTempSeries.map((series) => (
|
||||||
|
<ThresholdBarGraph
|
||||||
|
key={series.name}
|
||||||
|
graphId={`${series.name}-temp`}
|
||||||
|
name={series.name}
|
||||||
|
unit="°C"
|
||||||
|
threshold={DetectorTempThreshold}
|
||||||
|
updateTimes={updateTimes}
|
||||||
|
data={[series]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="aspect-video w-full" />
|
||||||
|
)}
|
||||||
|
|
||||||
{statsHistory[0]?.npu_usages && (
|
{statsHistory[0]?.npu_usages && (
|
||||||
<>
|
<>
|
||||||
@@ -834,6 +923,30 @@ export default function GeneralMetrics({
|
|||||||
) : (
|
) : (
|
||||||
<Skeleton className="aspect-video w-full" />
|
<Skeleton className="aspect-video w-full" />
|
||||||
)}
|
)}
|
||||||
|
{statsHistory.length != 0 ? (
|
||||||
|
<>
|
||||||
|
{npuTempSeries && npuTempSeries?.length != 0 && (
|
||||||
|
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||||
|
<div className="mb-5">
|
||||||
|
{t("general.hardwareInfo.npuTemperature")}
|
||||||
|
</div>
|
||||||
|
{npuTempSeries.map((series) => (
|
||||||
|
<ThresholdBarGraph
|
||||||
|
key={series.name}
|
||||||
|
graphId={`${series.name}-temp`}
|
||||||
|
name={series.name}
|
||||||
|
unit="°C"
|
||||||
|
threshold={DetectorTempThreshold}
|
||||||
|
updateTimes={updateTimes}
|
||||||
|
data={[series]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="aspect-video w-full" />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { defineConfig } from "vite";
|
|||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import monacoEditorPlugin from "vite-plugin-monaco-editor";
|
import monacoEditorPlugin from "vite-plugin-monaco-editor";
|
||||||
|
|
||||||
const proxyHost = process.env.PROXY_HOST || "localhost:5000";
|
const proxyHost = process.env.PROXY_HOST || "1ocalhost:5000";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user