Compare commits

..

5 Commits

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

* formatting

* move stall calcs to watchdog

* clean up

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

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

* Show cases separately from exports

* Add proper filtering and display of cases

* Add ability to edit and select cases for exports

* Cleanup typing

* Hide if no unassigned

* Cleanup hiding logic

* fix scrolling

* Improve layout
2025-12-15 13:10:50 -07:00
Josh Hawkins
a08c044144
refactor vainfo to search for first GPU (#21296)
use existing LibvaGpuSelector to pick appropritate libva device
2025-12-15 08:58:50 -07:00
Nicolas Mowen
5cced22f65
implement case management for export apis (#21295) 2025-12-15 08:54:13 -07:00
Nicolas Mowen
b962c95725
Create scaffolding for case management (#21293) 2025-12-15 08:28:52 -07:00
23 changed files with 1363 additions and 198 deletions

View File

@ -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,

View File

@ -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",
)

View File

@ -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",
)

View File

@ -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]

View File

@ -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):

View File

@ -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,181 @@ 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,
camera: Optional[List[str]] = Query(default=None),
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 camera:
filtered_cameras = [c for c in camera if c in allowed_cameras]
if not filtered_cameras:
return JSONResponse(content=[])
query = query.where(Export.camera << filtered_cameras)
if start_date is not None:
query = query.where(Export.date >= start_date)
if end_date is not None:
query = query.where(Export.date <= end_date)
exports = query.order_by(Export.date.desc()).dicts().iterator()
return JSONResponse(content=[e for e in exports]) 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 +266,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 +344,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(

View File

@ -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:

View File

@ -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):

View File

@ -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)
@ -348,8 +350,7 @@ 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,
@ -358,7 +359,11 @@ class RecordingExporter(threading.Thread):
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:

View File

@ -279,6 +279,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,6 +316,7 @@ 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"] = {}

View File

@ -584,12 +584,17 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess: 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]:

View File

@ -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}."
) )
if can_restart:
self.reset_capture_thread(terminate=False) 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..."
) )
if can_restart:
self.reset_capture_thread(drain_output=False) 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..."
) )
if can_restart:
self.reset_capture_thread() 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()

View File

@ -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

View File

@ -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

View File

@ -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"
} }
} }

View File

@ -151,6 +151,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."

View File

@ -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>
);
}

View File

@ -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 && (
<>
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
<div className="absolute right-3 top-2">
<div className="flex items-center justify-center gap-4">
{!exportedRecording.in_progress && ( {!exportedRecording.in_progress && (
<Tooltip> <div className="absolute bottom-2 right-3 z-40">
<TooltipTrigger asChild> <DropdownMenu modal={false}>
<DropdownMenuTrigger>
<BlurredIconButton <BlurredIconButton
onClick={() => aria-label={t("tooltip.editName")}
onClick={(e) => e.stopPropagation()}
>
<FiMoreVertical className="size-5" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.shareExport")}
onClick={(e) => {
e.stopPropagation();
shareOrCopy( shareOrCopy(
`${baseUrl}export?id=${exportedRecording.id}`, `${baseUrl}export?id=${exportedRecording.id}`,
exportedRecording.name.replaceAll("_", " "), exportedRecording.name.replaceAll("_", " "),
) );
} }}
>
{t("tooltip.shareExport")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.downloadVideo")}
> >
<FaShareAlt className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.shareExport")}</TooltipContent>
</Tooltip>
)}
{!exportedRecording.in_progress && (
<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>
<TooltipTrigger asChild>
<BlurredIconButton>
<FaDownload className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("tooltip.downloadVideo")} {t("tooltip.downloadVideo")}
</TooltipContent>
</Tooltip>
</a> </a>
</DropdownMenuItem>
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.assignToCase")}
onClick={(e) => {
e.stopPropagation();
onAssignToCase(exportedRecording);
}}
>
{t("tooltip.assignToCase")}
</DropdownMenuItem>
)} )}
{isAdmin && !exportedRecording.in_progress && ( {isAdmin && (
<Tooltip> <DropdownMenuItem
<TooltipTrigger asChild> className="cursor-pointer"
<BlurredIconButton aria-label={t("tooltip.editName")}
onClick={() => onClick={(e) => {
e.stopPropagation();
setEditName({ setEditName({
original: exportedRecording.name, original: exportedRecording.name,
update: undefined, update: undefined,
}) });
} }}
> >
<MdEditSquare className="size-4" /> {t("tooltip.editName")}
</BlurredIconButton> </DropdownMenuItem>
</TooltipTrigger>
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
</Tooltip>
)} )}
{isAdmin && ( {isAdmin && (
<Tooltip> <DropdownMenuItem
<TooltipTrigger asChild> className="cursor-pointer"
<BlurredIconButton aria-label={t("tooltip.deleteExport")}
onClick={() => onClick={(e) => {
e.stopPropagation();
onDelete({ onDelete({
file: exportedRecording.id, file: exportedRecording.id,
exportName: exportedRecording.name, exportName: exportedRecording.name,
}) });
}
>
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
</Tooltip>
)}
</div>
</div>
{!exportedRecording.in_progress && (
<Button
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
aria-label={t("button.play", { ns: "common" })}
variant="ghost"
onClick={() => {
onSelect(exportedRecording);
}} }}
> >
<FaPlay /> {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" />

View File

@ -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>
);
}

View File

@ -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,15 +11,24 @@ 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 { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export"; import { DeleteClipType, Export, ExportCase } from "@/types/export";
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -29,32 +38,37 @@ import useSWR from "swr";
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]);
// Data
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
const { data: rawExports, mutate: updateExports } =
useSWR<Export[]>("exports");
const exports = useMemo<Export[]>(
() => (rawExports ?? []).filter((e) => !e.export_case),
[rawExports],
);
const mutate = useCallback(() => {
updateExports();
updateCases();
}, [updateExports, updateCases]);
// Search // 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] = useOverlayState<
string | undefined
>("caseId", undefined);
const [selectedAspect, setSelectedAspect] = useState(0.0); const [selectedAspect, setSelectedAspect] = useState(0.0);
useSearchEffect("id", (id) => { useSearchEffect("id", (id) => {
@ -66,9 +80,25 @@ function Exports() {
return true; return true;
}); });
// Deleting useSearchEffect("caseId", (caseId: string) => {
if (!cases) {
return false;
}
const exists = cases.some((c) => c.id === caseId);
if (!exists) {
return false;
}
setSelectedCaseId(caseId);
return true;
});
// Modifying
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>(); const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
const onHandleDelete = useCallback(() => { const onHandleDelete = useCallback(() => {
if (!deleteClip) { if (!deleteClip) {
@ -83,8 +113,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 +135,7 @@ function Exports() {
}); });
}); });
}, },
[mutate, t], [mutate, setDeleteClip, t],
); );
// Keyboard Listener // Keyboard Listener
@ -115,10 +143,27 @@ function Exports() {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
useKeyboardListener([], undefined, contentRef); useKeyboardListener([], undefined, contentRef);
const selectedCase = useMemo(
() => cases?.find((c) => c.id === selectedCaseId),
[cases, 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,7 +232,7 @@ function Exports() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{exports && ( {(exports?.length || cases?.length) && (
<div className="flex w-full items-center justify-center p-2"> <div className="flex w-full items-center justify-center p-2">
<Input <Input
className="text-md w-full bg-muted md:w-1/3" className="text-md w-full bg-muted md:w-1/3"
@ -198,27 +243,143 @@ function Exports() {
</div> </div>
)} )}
{selectedCase ? (
<CaseView
contentRef={contentRef}
selectedCase={selectedCase}
exports={rawExports}
search={search}
setSelected={setSelected}
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
/>
) : (
<AllExportsView
contentRef={contentRef}
search={search}
cases={cases}
exports={exports}
setSelectedCaseId={setSelectedCaseId}
setSelected={setSelected}
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
/>
)}
</div>
);
}
type AllExportsViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
search: string;
cases?: ExportCase[];
exports: Export[];
setSelectedCaseId: (id: string) => void;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
};
function AllExportsView({
contentRef,
search,
cases,
exports,
setSelectedCaseId,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
}: AllExportsViewProps) {
const { t } = useTranslation(["views/exports"]);
// Filter
const filteredCases = useMemo(() => {
if (!search || !cases) {
return cases || [];
}
return cases.filter(
(caseItem) =>
caseItem.name.toLowerCase().includes(search.toLowerCase()) ||
(caseItem.description &&
caseItem.description.toLowerCase().includes(search.toLowerCase())),
);
}, [search, cases]);
const filteredExports = useMemo<Export[]>(() => {
if (!search) {
return exports;
}
return exports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [exports, search]);
return (
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
{exports && filteredExports && filteredExports.length > 0 ? ( {filteredCases?.length || filteredExports.length ? (
<div <div
ref={contentRef} ref={contentRef}
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto"
> >
{Object.values(exports).map((item) => ( {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 <ExportCard
key={item.name} key={item.name}
className={ className={
search == "" || filteredExports.includes(item) ? "" : "hidden" search == "" || filteredExports.includes(item)
? ""
: "hidden"
} }
exportedRecording={item} exportedRecording={item}
onSelect={setSelected} onSelect={setSelected}
onRename={onHandleRename} onRename={renameClip}
onDelete={({ file, exportName }) => onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName }) setDeleteClip({ file, exportName })
} }
onAssignToCase={onAssignToCase}
/> />
))} ))}
</div> </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"> <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" /> <LuFolderX className="size-16" />
@ -226,8 +387,194 @@ function Exports() {
</div> </div>
)} )}
</div> </div>
);
}
type CaseViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
selectedCase: ExportCase;
exports?: Export[];
search: string;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
};
function CaseView({
contentRef,
selectedCase,
exports,
search,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
}: CaseViewProps) {
const filteredExports = useMemo<Export[]>(() => {
const caseExports = (exports || []).filter(
(e) => e.export_case == selectedCase.id,
);
if (!search) {
return caseExports;
}
return caseExports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [selectedCase, exports, search]);
return (
<div className="flex size-full flex-col gap-8">
<div className="flex flex-col gap-1">
<Heading className="capitalize" as="h2">
{selectedCase.name}
</Heading>
<div className="text-secondary-foreground">
{selectedCase.description}
</div>
</div>
<div
ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{exports?.map((item) => (
<ExportCard
key={item.name}
className={filteredExports.includes(item) ? "" : "hidden"}
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
onAssignToCase={onAssignToCase}
/>
))}
</div>
</div> </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;

View File

@ -6,6 +6,15 @@ 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 = {

View File

@ -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 = {

View File

@ -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,9 +283,38 @@ 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="flex items-center gap-2">
<div className="text-sm font-medium text-muted-foreground smart-capitalize"> <div className="text-sm font-medium text-muted-foreground smart-capitalize">
<CameraNameLabel camera={camera} /> <CameraNameLabel camera={camera} />
</div> </div>
{statsHistory.length > 0 &&
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
] && (
<ConnectionQualityIndicator
quality={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.connection_quality
}
expectedFps={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.expected_fps || 0
}
reconnects={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.reconnects_last_hour || 0
}
stalls={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.stalls_last_hour || 0
}
/>
)}
</div>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<MdInfo <MdInfo