Export improvements (#22867)

* backend

* frontend + i18n

* tests + api spec

* tweak backend to use Job infrastructure for exports

* frontend tweaks and Job infrastructure

* tests

* tweaks

- add ability to remove from case
- change location of counts in case card

* add stale export reaper on startup

* fix toaster close button color

* improve add dialog

* formatting

* hide max_concurrent from camera config export settings

* remove border

* refactor batch endpoint for multiple review items

* frontend

* tests and fastapi spec

* fix deletion of in-progress exports in a case

* tweaks

- hide cases when filtering cameras that have no exports from those cameras
- remove description from case card
- use textarea instead of input for case description in add new case dialog

* add auth exceptions for exports

* add e2e test for deleting cases with exports

* refactor delete and case endpoints

allow bulk deleting and reassigning

* frontend

- bulk selection like Review
- gate admin-only actions
- consolidate dialogs
- spacing/padding tweaks

* i18n and tests

* update openapi spec

* tweaks

- add None to case selection list
- allow new case creation from single cam export dialog

* fix codeql

* fix i18n

* remove unused

* fix frontend tests
This commit is contained in:
Josh Hawkins
2026-04-14 09:19:50 -05:00
committed by GitHub
parent 18c068a3f9
commit e7e6f87682
31 changed files with 6789 additions and 733 deletions
+299 -63
View File
@@ -2724,6 +2724,135 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/exports/batch:
post:
tags:
- Export
summary: Start recording export batch
description: >-
Starts recording exports for a batch of items, each with its own camera
and time range. Optionally assigns them to a new or existing export case.
When neither export_case_id nor new_case_name is provided, exports are
added as uncategorized. Attaching to an existing case is admin-only.
operationId: export_recordings_batch_exports_batch_post
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BatchExportBody"
responses:
"202":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/BatchExportResponse"
"400":
description: Bad Request
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"403":
description: Forbidden
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"404":
description: Not Found
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"503":
description: Service Unavailable
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/exports/delete:
post:
tags:
- Export
summary: Bulk delete exports
description: >-
Deletes one or more exports by ID. All IDs must exist and none can be
in-progress. Admin-only.
operationId: bulk_delete_exports_exports_delete_post
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ExportBulkDeleteBody"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"400":
description: Bad Request - one or more exports are in-progress
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"404":
description: Not Found - one or more export IDs do not exist
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/exports/reassign:
post:
tags:
- Export
summary: Bulk reassign exports to a case
description: >-
Assigns or unassigns one or more exports to/from a case. All IDs must
exist. Pass export_case_id as null to unassign (move to uncategorized).
Admin-only.
operationId: bulk_reassign_exports_exports_reassign_post
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ExportBulkReassignBody"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"404":
description: Not Found - one or more export IDs or the target case do not exist
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/cases:
get:
tags:
@@ -2853,39 +2982,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
"/export/{export_id}/case":
patch:
tags:
- Export
summary: Assign export to case
description: "Assigns an export to a case, or unassigns it if export_case_id is null."
operationId: assign_export_case_export__export_id__case_patch
parameters:
- name: export_id
in: path
required: true
schema:
type: string
title: Export Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ExportCaseAssignBody"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
"/export/{camera_name}/start/{start_time}/end/{end_time}":
post:
tags:
@@ -2973,32 +3069,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
"/export/{event_id}":
delete:
tags:
- Export
summary: Delete export
operationId: export_delete_export__event_id__delete
parameters:
- name: event_id
in: path
required: true
schema:
type: string
title: Event Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/GenericResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
"/export/custom/{camera_name}/start/{start_time}/end/{end_time}":
post:
tags:
@@ -6501,6 +6571,149 @@ components:
required:
- recognizedLicensePlate
title: EventsLPRBody
BatchExportBody:
properties:
items:
items:
$ref: "#/components/schemas/BatchExportItem"
type: array
minItems: 1
maxItems: 50
title: Items
description: List of export items. Each item has its own camera and time range.
export_case_id:
anyOf:
- type: string
maxLength: 30
- type: "null"
title: Export case ID
description: Existing export case ID to assign all exports to. Attaching to an existing case is temporarily admin-only until case-level ACLs exist.
new_case_name:
anyOf:
- type: string
maxLength: 100
- type: "null"
title: New case name
description: Name of a new export case to create when export_case_id is omitted
new_case_description:
anyOf:
- type: string
- type: "null"
title: New case description
description: Optional description for a newly created export case
type: object
required:
- items
title: BatchExportBody
BatchExportItem:
properties:
camera:
type: string
title: Camera name
start_time:
type: number
title: Start time
end_time:
type: number
title: End time
image_path:
anyOf:
- type: string
- type: "null"
title: Existing thumbnail path
description: Optional existing image to use as the export thumbnail
friendly_name:
anyOf:
- type: string
maxLength: 256
- type: "null"
title: Friendly name
description: Optional friendly name for this specific export item
client_item_id:
anyOf:
- type: string
maxLength: 128
- type: "null"
title: Client item ID
description: Optional opaque client identifier echoed back in results
type: object
required:
- camera
- start_time
- end_time
title: BatchExportItem
BatchExportResponse:
properties:
export_case_id:
anyOf:
- type: string
- type: "null"
title: Export Case Id
description: Export case ID associated with the batch
export_ids:
items:
type: string
type: array
title: Export Ids
description: Export IDs successfully queued
results:
items:
$ref: "#/components/schemas/BatchExportResultModel"
type: array
title: Results
description: Per-item batch export results
type: object
required:
- export_ids
- results
title: BatchExportResponse
description: Response model for starting an export batch.
BatchExportResultModel:
properties:
camera:
type: string
title: Camera
description: Camera name for this export attempt
export_id:
anyOf:
- type: string
- type: "null"
title: Export Id
description: The export ID when the export was successfully queued
success:
type: boolean
title: Success
description: Whether the export was successfully queued
status:
anyOf:
- type: string
- type: "null"
title: Status
description: Queue status for this camera export
error:
anyOf:
- type: string
- type: "null"
title: Error
description: Validation or queueing error for this item, if any
item_index:
anyOf:
- type: integer
- type: "null"
title: Item Index
description: Zero-based index of this result within the request items list
client_item_id:
anyOf:
- type: string
- type: "null"
title: Client Item Id
description: Opaque client-supplied item identifier echoed from the request
type: object
required:
- camera
- success
title: BatchExportResultModel
description: Per-item result for a batch export request.
EventsSubLabelBody:
properties:
subLabel:
@@ -6523,18 +6736,41 @@ components:
required:
- subLabel
title: EventsSubLabelBody
ExportCaseAssignBody:
ExportBulkDeleteBody:
properties:
ids:
items:
type: string
minLength: 1
type: array
minItems: 1
title: Ids
type: object
required:
- ids
title: ExportBulkDeleteBody
description: Request body for bulk deleting exports.
ExportBulkReassignBody:
properties:
ids:
items:
type: string
minLength: 1
type: array
minItems: 1
title: Ids
export_case_id:
anyOf:
- type: string
maxLength: 30
- type: "null"
title: Export Case Id
description: "Case ID to assign to the export, or null to unassign"
description: "Case ID to assign to, or null to unassign from current case"
type: object
title: ExportCaseAssignBody
description: Request body for assigning or unassigning an export to a case.
required:
- ids
title: ExportBulkReassignBody
description: Request body for bulk reassigning exports to a case.
ExportCaseCreateBody:
properties:
name:
+4
View File
@@ -88,7 +88,9 @@ def require_admin_by_default():
"/go2rtc/streams",
"/event_ids",
"/events",
"/cases",
"/exports",
"/jobs/export",
}
# Path prefixes that should be exempt (for paths with parameters)
@@ -101,7 +103,9 @@ def require_admin_by_default():
"/go2rtc/streams/", # /go2rtc/streams/{camera}
"/users/", # /users/{username}/password (has own auth)
"/preview/", # /preview/{file}/thumbnail.jpg
"/cases/", # /cases/{case_id}
"/exports/", # /exports/{export_id}
"/jobs/export/", # /jobs/export/{export_id}
"/vod/", # /vod/{camera_name}/...
"/notifications/", # /notifications/pubkey, /notifications/register
)
@@ -0,0 +1,65 @@
from typing import List, Optional
from pydantic import BaseModel, Field, model_validator
MAX_BATCH_EXPORT_ITEMS = 50
class BatchExportItem(BaseModel):
camera: str = Field(title="Camera name")
start_time: float = Field(title="Start time")
end_time: float = Field(title="End time")
image_path: Optional[str] = Field(
default=None,
title="Existing thumbnail path",
description="Optional existing image to use as the export thumbnail",
)
friendly_name: Optional[str] = Field(
default=None,
title="Friendly name",
max_length=256,
description="Optional friendly name for this specific export item",
)
client_item_id: Optional[str] = Field(
default=None,
title="Client item ID",
max_length=128,
description="Optional opaque client identifier echoed back in results",
)
class BatchExportBody(BaseModel):
items: List[BatchExportItem] = Field(
title="Items",
min_length=1,
max_length=MAX_BATCH_EXPORT_ITEMS,
description="List of export items. Each item has its own camera and time range.",
)
export_case_id: Optional[str] = Field(
default=None,
title="Export case ID",
max_length=30,
description=(
"Existing export case ID to assign all exports to. Attaching to an "
"existing case is temporarily admin-only until case-level ACLs exist."
),
)
new_case_name: Optional[str] = Field(
default=None,
title="New case name",
max_length=100,
description="Name of a new export case to create when export_case_id is omitted",
)
new_case_description: Optional[str] = Field(
default=None,
title="New case description",
description="Optional description for a newly created export case",
)
@model_validator(mode="after")
def validate_case_target(self) -> "BatchExportBody":
for item in self.items:
if item.end_time <= item.start_time:
raise ValueError("end_time must be after start_time")
return self
@@ -0,0 +1,24 @@
"""Request bodies for bulk export operations."""
from typing import Optional
from pydantic import BaseModel, Field, conlist, constr
class ExportBulkDeleteBody(BaseModel):
"""Request body for bulk deleting exports."""
# List of export IDs with at least one element and each element with at least one char
ids: conlist(constr(min_length=1), min_length=1)
class ExportBulkReassignBody(BaseModel):
"""Request body for bulk reassigning exports to a case."""
# List of export IDs with at least one element and each element with at least one char
ids: conlist(constr(min_length=1), min_length=1)
export_case_id: Optional[str] = Field(
default=None,
max_length=30,
description="Case ID to assign to, or null to unassign from current case",
)
@@ -23,13 +23,3 @@ class ExportCaseUpdateBody(BaseModel):
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",
)
+83 -1
View File
@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import Any, List, Optional
from pydantic import BaseModel, Field
@@ -28,6 +28,88 @@ class StartExportResponse(BaseModel):
export_id: Optional[str] = Field(
default=None, description="The export ID if successfully started"
)
status: Optional[str] = Field(
default=None,
description="Queue status for the export job",
)
class BatchExportResultModel(BaseModel):
"""Per-item result for a batch export request."""
camera: str = Field(description="Camera name for this export attempt")
export_id: Optional[str] = Field(
default=None,
description="The export ID when the export was successfully queued",
)
success: bool = Field(description="Whether the export was successfully queued")
status: Optional[str] = Field(
default=None,
description="Queue status for this camera export",
)
error: Optional[str] = Field(
default=None,
description="Validation or queueing error for this item, if any",
)
item_index: Optional[int] = Field(
default=None,
description="Zero-based index of this result within the request items list",
)
client_item_id: Optional[str] = Field(
default=None,
description="Opaque client-supplied item identifier echoed from the request",
)
class BatchExportResponse(BaseModel):
"""Response model for starting an export batch."""
export_case_id: Optional[str] = Field(
default=None,
description="Export case ID associated with the batch",
)
export_ids: List[str] = Field(description="Export IDs successfully queued")
results: List[BatchExportResultModel] = Field(
description="Per-item batch export results"
)
class ExportJobModel(BaseModel):
"""Model representing a queued or running export job."""
id: str = Field(description="Unique identifier for the export job")
job_type: str = Field(description="Job type")
status: str = Field(description="Current job status")
camera: str = Field(description="Camera associated with this export job")
name: Optional[str] = Field(
default=None,
description="Friendly name for the export",
)
export_case_id: Optional[str] = Field(
default=None,
description="ID of the export case this export belongs to",
)
request_start_time: float = Field(description="Requested export start time")
request_end_time: float = Field(description="Requested export end time")
start_time: Optional[float] = Field(
default=None,
description="Unix timestamp when execution started",
)
end_time: Optional[float] = Field(
default=None,
description="Unix timestamp when execution completed",
)
error_message: Optional[str] = Field(
default=None,
description="Error message for failed jobs",
)
results: Optional[dict[str, Any]] = Field(
default=None,
description="Result metadata for completed jobs",
)
ExportJobsResponse = List[ExportJobModel]
ExportsResponse = List[ExportModel]
+634 -259
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -52,6 +52,7 @@ from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
from frigate.events.audio import AudioProcessor
from frigate.events.cleanup import EventCleanup
from frigate.events.maintainer import EventProcessor
from frigate.jobs.export import reap_stale_exports
from frigate.jobs.motion_search import stop_all_motion_search_jobs
from frigate.log import _stop_logging
from frigate.models import (
@@ -611,6 +612,11 @@ class FrigateApp:
# Clean up any stale replay camera artifacts (filesystem + DB)
cleanup_replay_cameras()
# Reap any Export rows still marked in_progress from a previous
# session (crash, kill, broken migration). Runs synchronously before
# uvicorn binds so no API request can observe a stale row.
reap_stale_exports()
self.init_inter_process_communicator()
self.start_detectors()
self.init_dispatcher()
+6
View File
@@ -92,6 +92,12 @@ class RecordExportConfig(FrigateBaseModel):
title="Export hwaccel args",
description="Hardware acceleration args to use for export/transcode operations.",
)
max_concurrent: int = Field(
default=3,
ge=1,
title="Maximum concurrent exports",
description="Maximum number of export jobs to process at the same time.",
)
class RecordConfig(FrigateBaseModel):
+387
View File
@@ -0,0 +1,387 @@
"""Export job management with queued background execution."""
import logging
import os
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from queue import Full, Queue
from typing import Any, Optional
from peewee import DoesNotExist
from frigate.config import FrigateConfig
from frigate.jobs.job import Job
from frigate.models import Export
from frigate.record.export import PlaybackSourceEnum, RecordingExporter
from frigate.types import JobStatusTypesEnum
logger = logging.getLogger(__name__)
# Maximum number of jobs that can sit in the queue waiting to run.
# Prevents a runaway client from unbounded memory growth.
MAX_QUEUED_EXPORT_JOBS = 100
class ExportQueueFullError(RuntimeError):
"""Raised when the export queue is at capacity."""
@dataclass
class ExportJob(Job):
"""Job state for export operations."""
job_type: str = "export"
camera: str = ""
name: Optional[str] = None
image_path: Optional[str] = None
export_case_id: Optional[str] = None
request_start_time: float = 0.0
request_end_time: float = 0.0
playback_source: str = PlaybackSourceEnum.recordings.value
ffmpeg_input_args: Optional[str] = None
ffmpeg_output_args: Optional[str] = None
cpu_fallback: bool = False
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for API responses.
Only exposes fields that are part of the public ExportJobModel schema.
Internal execution details (image_path, ffmpeg args, cpu_fallback) are
intentionally omitted so they don't leak through the API.
"""
return {
"id": self.id,
"job_type": self.job_type,
"status": self.status,
"camera": self.camera,
"name": self.name,
"export_case_id": self.export_case_id,
"request_start_time": self.request_start_time,
"request_end_time": self.request_end_time,
"start_time": self.start_time,
"end_time": self.end_time,
"error_message": self.error_message,
"results": self.results,
}
class ExportQueueWorker(threading.Thread):
"""Worker that executes queued exports."""
def __init__(self, manager: "ExportJobManager", worker_index: int) -> None:
super().__init__(
daemon=True,
name=f"export_queue_worker_{worker_index}",
)
self.manager = manager
def run(self) -> None:
while True:
job = self.manager.queue.get()
try:
self.manager.run_job(job)
except Exception:
logger.exception(
"Export queue worker failed while processing %s", job.id
)
finally:
self.manager.queue.task_done()
class ExportJobManager:
"""Concurrency-limited manager for queued export jobs."""
def __init__(
self,
config: FrigateConfig,
max_concurrent: int,
max_queued: int = MAX_QUEUED_EXPORT_JOBS,
) -> None:
self.config = config
self.max_concurrent = max(1, max_concurrent)
self.queue: Queue[ExportJob] = Queue(maxsize=max(1, max_queued))
self.jobs: dict[str, ExportJob] = {}
self.lock = threading.Lock()
self.workers: list[ExportQueueWorker] = []
self.started = False
def ensure_started(self) -> None:
"""Ensure worker threads are started exactly once."""
with self.lock:
if self.started:
self._restart_dead_workers_locked()
return
for index in range(self.max_concurrent):
worker = ExportQueueWorker(self, index)
worker.start()
self.workers.append(worker)
self.started = True
def _restart_dead_workers_locked(self) -> None:
for index, worker in enumerate(self.workers):
if worker.is_alive():
continue
logger.error(
"Export queue worker %s died unexpectedly, restarting", worker.name
)
replacement = ExportQueueWorker(self, index)
replacement.start()
self.workers[index] = replacement
def enqueue(self, job: ExportJob) -> str:
"""Queue a job for background execution.
Raises ExportQueueFullError if the queue is at capacity.
"""
self.ensure_started()
try:
self.queue.put_nowait(job)
except Full as err:
raise ExportQueueFullError(
"Export queue is full; try again once current exports finish"
) from err
with self.lock:
self.jobs[job.id] = job
return job.id
def get_job(self, job_id: str) -> Optional[ExportJob]:
"""Get a job by ID."""
with self.lock:
return self.jobs.get(job_id)
def list_active_jobs(self) -> list[ExportJob]:
"""List queued and running jobs."""
with self.lock:
return [
job
for job in self.jobs.values()
if job.status in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running)
]
def cancel_queued_jobs_for_case(self, case_id: str) -> list[ExportJob]:
"""Cancel queued export jobs assigned to a deleted case."""
cancelled_jobs: list[ExportJob] = []
with self.lock:
with self.queue.mutex:
retained_jobs: list[ExportJob] = []
while self.queue.queue:
job = self.queue.queue.popleft()
if (
job.export_case_id == case_id
and job.status == JobStatusTypesEnum.queued
):
job.status = JobStatusTypesEnum.cancelled
job.end_time = time.time()
cancelled_jobs.append(job)
continue
retained_jobs.append(job)
self.queue.queue.extend(retained_jobs)
if cancelled_jobs:
self.queue.unfinished_tasks = max(
0,
self.queue.unfinished_tasks - len(cancelled_jobs),
)
if self.queue.unfinished_tasks == 0:
self.queue.all_tasks_done.notify_all()
self.queue.not_full.notify_all()
return cancelled_jobs
def available_slots(self) -> int:
"""Approximate number of additional jobs that could be queued right now.
Uses Queue.qsize() which is best-effort; callers should treat the
result as advisory since another thread could enqueue between
checking and enqueueing.
"""
return max(0, self.queue.maxsize - self.queue.qsize())
def run_job(self, job: ExportJob) -> None:
"""Execute a queued export job."""
job.status = JobStatusTypesEnum.running
job.start_time = time.time()
exporter = RecordingExporter(
self.config,
job.id,
job.camera,
job.name,
job.image_path,
int(job.request_start_time),
int(job.request_end_time),
PlaybackSourceEnum(job.playback_source),
job.export_case_id,
job.ffmpeg_input_args,
job.ffmpeg_output_args,
job.cpu_fallback,
)
try:
exporter.run()
export = Export.get_or_none(Export.id == job.id)
if export is None:
job.status = JobStatusTypesEnum.failed
job.error_message = "Export failed"
elif export.in_progress:
job.status = JobStatusTypesEnum.failed
job.error_message = "Export did not complete"
else:
job.status = JobStatusTypesEnum.success
job.results = {
"export_id": export.id,
"export_case_id": export.export_case_id,
"video_path": export.video_path,
"thumb_path": export.thumb_path,
}
except DoesNotExist:
job.status = JobStatusTypesEnum.failed
job.error_message = "Export not found"
except Exception as err:
logger.exception("Export job %s failed: %s", job.id, err)
job.status = JobStatusTypesEnum.failed
job.error_message = str(err)
finally:
job.end_time = time.time()
_job_manager: Optional[ExportJobManager] = None
_job_manager_lock = threading.Lock()
def _get_max_concurrent(config: FrigateConfig) -> int:
return int(config.record.export.max_concurrent)
def reap_stale_exports() -> None:
"""Sweep Export rows stuck with in_progress=True from previous sessions.
On Frigate startup no export job is alive yet, so any in_progress=True
row must be a leftover from a previous session that crashed, was killed
mid-export, or returned early from RecordingExporter.run() without
flipping the flag. For each stale row we either:
- delete the row (and any thumb) if the video file is missing or empty,
since there is nothing worth recovering
- flip in_progress to False if the video file exists on disk and is
non-empty, treating it as a completed export the user can manage
through the normal UI
Must only be called when the export job manager is certain to have no
active jobs — i.e., at Frigate startup, before any worker runs.
All exceptions are caught and logged; the caller does not need to wrap
this in a try/except. A failure on a single row will not stop the rest
of the sweep, and a failure in the top-level query will log and return.
"""
try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception:
logger.exception("Failed to query stale in-progress exports")
return
if not stale_exports:
logger.debug("No stale in-progress exports found on startup")
return
flipped = 0
deleted = 0
errored = 0
for export in stale_exports:
try:
video_path = export.video_path
has_usable_file = False
if video_path:
try:
has_usable_file = os.path.getsize(video_path) > 0
except OSError:
has_usable_file = False
if has_usable_file:
# Unassign from any case on recovery: the user should
# re-triage a recovered export rather than have it silently
# reappear inside a case they curated.
Export.update(
{Export.in_progress: False, Export.export_case: None}
).where(Export.id == export.id).execute()
flipped += 1
logger.info(
"Recovered stale in-progress export %s (file intact on disk)",
export.id,
)
continue
if export.thumb_path:
Path(export.thumb_path).unlink(missing_ok=True)
if video_path:
Path(video_path).unlink(missing_ok=True)
Export.delete().where(Export.id == export.id).execute()
deleted += 1
logger.info(
"Deleted stale in-progress export %s (no usable file on disk)",
export.id,
)
except Exception:
errored += 1
logger.exception("Failed to reap stale export %s", export.id)
logger.info(
"Stale export cleanup complete: %d recovered, %d deleted, %d errored",
flipped,
deleted,
errored,
)
def get_export_job_manager(config: FrigateConfig) -> ExportJobManager:
"""Get or create the singleton export job manager."""
global _job_manager
with _job_manager_lock:
if _job_manager is None:
_job_manager = ExportJobManager(config, _get_max_concurrent(config))
_job_manager.ensure_started()
return _job_manager
def start_export_job(config: FrigateConfig, job: ExportJob) -> str:
"""Queue an export job and return its ID."""
return get_export_job_manager(config).enqueue(job)
def get_export_job(config: FrigateConfig, job_id: str) -> Optional[ExportJob]:
"""Get a queued or completed export job by ID."""
return get_export_job_manager(config).get_job(job_id)
def list_active_export_jobs(config: FrigateConfig) -> list[ExportJob]:
"""List queued and running export jobs."""
return get_export_job_manager(config).list_active_jobs()
def cancel_queued_export_jobs_for_case(
config: FrigateConfig, case_id: str
) -> list[ExportJob]:
"""Cancel queued export jobs that still point at a deleted case."""
return get_export_job_manager(config).cancel_queued_jobs_for_case(case_id)
def available_export_queue_slots(config: FrigateConfig) -> int:
"""Approximate number of additional export jobs that could be queued now."""
return get_export_job_manager(config).available_slots()
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -82,14 +82,26 @@ export class ApiMocker {
route.fulfill({ json: stats }),
);
// Reviews
await this.page.route("**/api/reviews**", (route) => {
const url = route.request().url();
if (url.includes("summary")) {
return route.fulfill({ json: reviewSummary });
}
return route.fulfill({ json: reviews });
});
// Reviews. The real backend exposes /review (singular) for the main
// list and /review/summary for the summary — the previous plural glob
// (**/api/reviews**) never matched either endpoint, so review-dependent
// tests silently ran without data. The POST mutations at /reviews/viewed
// and /reviews/delete (plural) still fall through to the generic
// mutation catch-all further down the file.
await this.page.route(/\/api\/review\/summary/, (route) =>
route.fulfill({ json: reviewSummary }),
);
await this.page.route(/\/api\/review(\?|$)/, (route) =>
route.fulfill({ json: reviews }),
);
// Export jobs. The Exports page polls this every 2s while any export
// is in_progress; without a mock route it falls through to the preview
// server which returns 500 and makes the page flap between loading and
// rendered state, breaking tests that navigate to /export.
await this.page.route("**/api/jobs/export", (route) =>
route.fulfill({ json: [] }),
);
// Recordings summary
await this.page.route("**/api/recordings/summary**", (route) =>
+710 -50
View File
@@ -1,74 +1,734 @@
/**
* Export page tests -- HIGH tier.
*
* Tests export card rendering with mock data, search filtering,
* and delete confirmation dialog.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Export Page - Cards @high", () => {
test("export page renders export cards from mock data", async ({
test.describe("Export Page - Overview @high", () => {
test("renders uncategorized exports and case cards from mock data", async ({
frigateApp,
}) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// Should show export names from our mock data
await expect(
frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible({ timeout: 10_000 });
).toBeVisible();
await expect(
frigateApp.page.getByText("Backyard - Car Detection"),
frigateApp.page.getByText("Garage - In Progress"),
).toBeVisible();
await expect(
frigateApp.page.getByText("Package Theft Investigation"),
).toBeVisible();
});
test("export page shows in-progress indicator", async ({ frigateApp }) => {
test("search filters uncategorized exports", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// "Garage - In Progress" export should be visible
await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible(
{ timeout: 10_000 },
);
const searchInput = frigateApp.page.getByPlaceholder(/search/i).first();
await searchInput.fill("Front Door");
await expect(
frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible();
await expect(
frigateApp.page.getByText("Backyard - Car Detection"),
).toBeHidden();
await expect(
frigateApp.page.getByText("Garage - In Progress"),
).toBeHidden();
});
test("export page shows case grouping", async ({ frigateApp }) => {
test("new case button opens the create case dialog", async ({
frigateApp,
}) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(3000);
// Cases may render differently depending on API response shape
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
await frigateApp.page.getByRole("button", { name: "New Case" }).click();
await expect(
frigateApp.page.getByRole("dialog").filter({ hasText: "Create Case" }),
).toBeVisible();
await expect(frigateApp.page.getByPlaceholder("Case name")).toBeVisible();
});
});
test.describe("Export Page - Search @high", () => {
test("search input filters export list", async ({ frigateApp }) => {
test.describe("Export Page - Case Detail @high", () => {
test("opening a case shows its detail view and associated export", async ({
frigateApp,
}) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
const searchInput = frigateApp.page.locator(
'#pageRoot input[type="text"], #pageRoot input',
await frigateApp.page
.getByText("Package Theft Investigation")
.first()
.click();
await expect(
frigateApp.page.getByRole("heading", {
name: "Package Theft Investigation",
}),
).toBeVisible();
await expect(
frigateApp.page.getByText("Backyard - Car Detection"),
).toBeVisible();
await expect(
frigateApp.page.getByRole("button", { name: "Add Export" }),
).toBeVisible();
await expect(
frigateApp.page.getByRole("button", { name: "Edit Case" }),
).toBeVisible();
await expect(
frigateApp.page.getByRole("button", { name: "Delete Case" }),
).toBeVisible();
});
test("edit case opens a prefilled dialog", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page
.getByText("Package Theft Investigation")
.first()
.click();
await frigateApp.page.getByRole("button", { name: "Edit Case" }).click();
const dialog = frigateApp.page
.getByRole("dialog")
.filter({ hasText: "Edit Case" });
await expect(dialog).toBeVisible();
await expect(dialog.locator("input")).toHaveValue(
"Package Theft Investigation",
);
if (
(await searchInput.count()) > 0 &&
(await searchInput.first().isVisible())
) {
// Type a search term that matches one export
await searchInput.first().fill("Front Door");
await frigateApp.page.waitForTimeout(500);
// "Front Door - Person Alert" should still be visible
await expect(
frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible();
await expect(dialog.locator("textarea")).toHaveValue(
"Review of suspicious activity near the front porch",
);
});
test("add export shows completed uncategorized exports for assignment", async ({
frigateApp,
}) => {
await frigateApp.goto("/export");
await frigateApp.page
.getByText("Package Theft Investigation")
.first()
.click();
await frigateApp.page.getByRole("button", { name: "Add Export" }).click();
const dialog = frigateApp.page
.getByRole("dialog")
.filter({ hasText: "Add Export to Package Theft Investigation" });
await expect(dialog).toBeVisible();
// Completed, uncategorized exports are selectable
await expect(dialog.getByText("Front Door - Person Alert")).toBeVisible();
// In-progress exports are intentionally hidden by AssignExportDialog
// (see Exports.tsx filteredExports) — they can't be assigned until
// they finish, so they should not show in the picker.
await expect(dialog.getByText("Garage - In Progress")).toBeHidden();
});
test("delete case opens a confirmation dialog", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page
.getByText("Package Theft Investigation")
.first()
.click();
await frigateApp.page.getByRole("button", { name: "Delete Case" }).click();
const dialog = frigateApp.page
.getByRole("alertdialog")
.filter({ hasText: "Delete Case" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText(/Package Theft Investigation/)).toBeVisible();
});
test("delete case can also delete its exports", async ({ frigateApp }) => {
let deleteRequestUrl: string | null = null;
let deleteCaseCompleted = false;
const initialCases = [
{
id: "case-001",
name: "Package Theft Investigation",
description: "Review of suspicious activity near the front porch",
created_at: 1775407931.3863528,
updated_at: 1775483531.3863528,
},
];
const initialExports = [
{
id: "export-001",
camera: "front_door",
name: "Front Door - Person Alert",
date: 1775490731.3863528,
video_path: "/exports/export-001.mp4",
thumb_path: "/exports/export-001-thumb.jpg",
in_progress: false,
export_case_id: null,
},
{
id: "export-002",
camera: "backyard",
name: "Backyard - Car Detection",
date: 1775483531.3863528,
video_path: "/exports/export-002.mp4",
thumb_path: "/exports/export-002-thumb.jpg",
in_progress: false,
export_case_id: "case-001",
},
{
id: "export-003",
camera: "garage",
name: "Garage - In Progress",
date: 1775492531.3863528,
video_path: "/exports/export-003.mp4",
thumb_path: "/exports/export-003-thumb.jpg",
in_progress: true,
export_case_id: null,
},
];
await frigateApp.page.route(/\/api\/cases(?:$|\?|\/)/, async (route) => {
const request = route.request();
if (request.method() === "DELETE") {
deleteRequestUrl = request.url();
deleteCaseCompleted = true;
return route.fulfill({ json: { success: true } });
}
if (request.method() === "GET") {
return route.fulfill({
json: deleteCaseCompleted ? [] : initialCases,
});
}
return route.fallback();
});
await frigateApp.page.route("**/api/exports**", async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: deleteCaseCompleted
? initialExports.filter((exp) => exp.export_case_id !== "case-001")
: initialExports,
});
});
await frigateApp.goto("/export");
await frigateApp.page
.getByText("Package Theft Investigation")
.first()
.click();
await frigateApp.page.getByRole("button", { name: "Delete Case" }).click();
const dialog = frigateApp.page
.getByRole("alertdialog")
.filter({ hasText: "Delete Case" });
await expect(dialog).toBeVisible();
const deleteExportsSwitch = dialog.getByRole("switch", {
name: "Also delete exports",
});
await expect(deleteExportsSwitch).toHaveAttribute("aria-checked", "false");
await expect(
dialog.getByText(
"Exports will remain available as uncategorized exports.",
),
).toBeVisible();
await deleteExportsSwitch.click();
await expect(deleteExportsSwitch).toHaveAttribute("aria-checked", "true");
await expect(
dialog.getByText("All exports in this case will be permanently deleted."),
).toBeVisible();
await dialog.getByRole("button", { name: /^delete$/i }).click();
await expect
.poll(() => deleteRequestUrl)
.toContain("/api/cases/case-001?delete_exports=true");
await expect(dialog).toBeHidden();
await expect(
frigateApp.page.getByRole("heading", {
name: "Package Theft Investigation",
}),
).toBeHidden();
await expect(
frigateApp.page.getByText("Backyard - Car Detection"),
).toBeHidden();
await expect(
frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible();
});
});
test.describe("Export Page - Empty State @high", () => {
test("renders the empty state when there are no exports or cases", async ({
frigateApp,
}) => {
await frigateApp.page.route("**/api/export**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.page.route("**/api/exports**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.page.route("**/api/cases", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.page.route("**/api/cases**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.goto("/export");
await expect(frigateApp.page.getByText("No exports found")).toBeVisible();
});
});
test.describe("Export Page - Mobile @high @mobile", () => {
test("mobile can open an export preview dialog", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Mobile-only assertion");
await frigateApp.goto("/export");
await frigateApp.page
.getByText("Front Door - Person Alert")
.first()
.click();
const dialog = frigateApp.page
.getByRole("dialog")
.filter({ hasText: "Front Door - Person Alert" });
await expect(dialog).toBeVisible();
await expect(dialog.locator("video")).toBeVisible();
});
});
test.describe("Multi-Review Export @high", () => {
// Two alert reviews close enough to "now" to fall within the
// default last-24-hours review window. Using numeric timestamps
// because the TS ReviewSegment type expects numbers even though
// the backend pydantic model serializes datetime as ISO strings —
// the app reads these as numbers for display math.
const now = Date.now() / 1000;
const mockReviews = [
{
id: "mex-review-001",
camera: "front_door",
start_time: now - 600,
end_time: now - 580,
has_been_reviewed: false,
severity: "alert",
thumb_path: "/clips/front_door/mex-review-001-thumb.jpg",
data: {
audio: [],
detections: ["person-001"],
objects: ["person"],
sub_labels: [],
significant_motion_areas: [],
zones: ["front_yard"],
},
},
{
id: "mex-review-002",
camera: "backyard",
start_time: now - 1200,
end_time: now - 1170,
has_been_reviewed: false,
severity: "alert",
thumb_path: "/clips/backyard/mex-review-002-thumb.jpg",
data: {
audio: [],
detections: ["car-002"],
objects: ["car"],
sub_labels: [],
significant_motion_areas: [],
zones: ["driveway"],
},
},
];
// 51 alert reviews, all front_door, spaced 5 minutes apart. Used by the
// over-limit test to trigger Ctrl+A select-all and verify the Export
// button is hidden at 51 selected.
const oversizedReviews = Array.from({ length: 51 }, (_, i) => ({
id: `mex-oversized-${i.toString().padStart(3, "0")}`,
camera: "front_door",
start_time: now - 60 * 60 - i * 300,
end_time: now - 60 * 60 - i * 300 + 20,
has_been_reviewed: false,
severity: "alert",
thumb_path: `/clips/front_door/mex-oversized-${i}-thumb.jpg`,
data: {
audio: [],
detections: [`person-${i}`],
objects: ["person"],
sub_labels: [],
significant_motion_areas: [],
zones: ["front_yard"],
},
}));
const mockSummary = {
last24Hours: {
reviewed_alert: 0,
reviewed_detection: 0,
total_alert: 2,
total_detection: 0,
},
};
async function routeReviews(
page: import("@playwright/test").Page,
reviews: unknown[],
) {
// Intercept the actual `/api/review` endpoint (singular — the
// default api-mocker only registers `/api/reviews**` (plural)
// which does not match the real request URL).
await page.route(/\/api\/review(\?|$)/, (route) =>
route.fulfill({ json: reviews }),
);
await page.route(/\/api\/review\/summary/, (route) =>
route.fulfill({ json: mockSummary }),
);
}
test.beforeEach(async ({ frigateApp }) => {
await routeReviews(frigateApp.page, mockReviews);
// Empty cases list by default so the dialog defaults to "new case".
// Individual tests override this to populate existing cases.
await frigateApp.page.route("**/api/cases", (route) =>
route.fulfill({ json: [] }),
);
});
async function selectTwoReviews(frigateApp: {
page: import("@playwright/test").Page;
}) {
// Every review card has className `review-item` on its wrapper
// (see EventView.tsx). Cards also have data-start attributes that
// we can key off if needed.
const reviewItems = frigateApp.page.locator(".review-item");
await reviewItems.first().waitFor({ state: "visible", timeout: 10_000 });
// Meta-click the first two items to enter multi-select mode.
// PreviewThumbnailPlayer reads e.metaKey to decide multi-select.
await reviewItems.nth(0).click({ modifiers: ["Meta"] });
await reviewItems.nth(1).click();
}
test("selecting two reviews reveals the export button", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
await frigateApp.goto("/review");
await selectTwoReviews(frigateApp);
// Action group replaces the filter bar once items are selected
await expect(frigateApp.page.getByText(/2.*selected/i)).toBeVisible({
timeout: 5_000,
});
const exportButton = frigateApp.page.getByRole("button", {
name: /export/i,
});
await expect(exportButton).toBeVisible();
});
test("clicking export opens the multi-review dialog with correct title", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
await frigateApp.goto("/review");
await selectTwoReviews(frigateApp);
await frigateApp.page
.getByRole("button", { name: /export/i })
.first()
.click();
const dialog = frigateApp.page
.getByRole("dialog")
.filter({ hasText: /Export 2 reviews/i });
await expect(dialog).toBeVisible({ timeout: 5_000 });
// The dialog uses a Select trigger for case selection (admins). The
// default "None" value is shown on the trigger.
await expect(dialog.locator("button[role='combobox']")).toBeVisible();
await expect(dialog.getByText(/None/)).toBeVisible();
});
test("starting an export posts the expected payload and navigates to the case", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
let capturedPayload: unknown = null;
await frigateApp.page.route("**/api/exports/batch", async (route) => {
capturedPayload = route.request().postDataJSON();
await route.fulfill({
status: 202,
json: {
export_case_id: "new-case-xyz",
export_ids: ["front_door_a", "backyard_b"],
results: [
{
camera: "front_door",
export_id: "front_door_a",
success: true,
status: "queued",
error: null,
item_index: 0,
},
{
camera: "backyard",
export_id: "backyard_b",
success: true,
status: "queued",
error: null,
item_index: 1,
},
],
},
});
});
await frigateApp.goto("/review");
await selectTwoReviews(frigateApp);
await frigateApp.page
.getByRole("button", { name: /export/i })
.first()
.click();
const dialog = frigateApp.page
.getByRole("dialog")
.filter({ hasText: /Export 2 reviews/i });
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Select "Create new case" from the case dropdown (default is "None")
await dialog.locator("button[role='combobox']").click();
await frigateApp.page
.getByRole("option", { name: /Create new case/i })
.click();
const nameInput = dialog.locator("input").first();
await nameInput.fill("E2E Incident");
await dialog.getByRole("button", { name: /export 2 reviews/i }).click();
// Wait for the POST to fire
await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull();
const payload = capturedPayload as {
items: Array<{
camera: string;
start_time: number;
end_time: number;
image_path?: string;
client_item_id?: string;
}>;
new_case_name?: string;
export_case_id?: string;
};
expect(payload.items).toHaveLength(2);
expect(payload.new_case_name).toBe("E2E Incident");
// When creating a new case, we must NOT also send export_case_id —
// the two fields are mutually exclusive on the backend.
expect(payload.export_case_id).toBeUndefined();
expect(payload.items.map((i) => i.camera).sort()).toEqual([
"backyard",
"front_door",
]);
// Each item must preserve REVIEW_PADDING (4s) on the edges —
// i.e. the padded window is 8s longer than the original review.
// The mock reviews above have 20s and 30s raw durations, so the
// expected padded durations are 28s and 38s.
const paddedDurations = payload.items
.map((i) => i.end_time - i.start_time)
.sort((a, b) => a - b);
expect(paddedDurations).toEqual([28, 38]);
// Thumbnails should be passed through per item
for (const item of payload.items) {
expect(item.image_path).toMatch(/mex-review-\d+-thumb\.jpg$/);
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
expect(payload.items.map((item) => item.client_item_id)).toEqual([
"mex-review-001",
"mex-review-002",
]);
test.describe("Export Page - Controls @high", () => {
test("export page filter controls are present", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(1000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
timeout: 5_000,
});
});
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile-only Drawer assertion");
await frigateApp.goto("/review");
await selectTwoReviews(frigateApp);
await frigateApp.page
.getByRole("button", { name: /export/i })
.first()
.click();
// On mobile the component renders a shadcn Drawer, which uses
// role="dialog" but sets data-vaul-drawer. Desktop renders a
// shadcn Dialog with role="dialog" but no data-vaul-drawer.
// The title and submit button both contain "Export 2 reviews", so
// assert each element distinctly: the title is a heading and the
// submit button has role="button".
const drawer = frigateApp.page.locator("[data-vaul-drawer]");
await expect(drawer).toBeVisible({ timeout: 5_000 });
await expect(
drawer.getByRole("heading", { name: /Export 2 reviews/i }),
).toBeVisible();
await expect(
drawer.getByRole("button", { name: /export 2 reviews/i }),
).toBeVisible();
});
test("hides export button when more than 50 reviews are selected", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop select-all keyboard flow");
// Override the default 2-review mock with 51 reviews before
// navigation. Playwright matches routes last-registered-first so
// this takes precedence over the beforeEach.
await routeReviews(frigateApp.page, oversizedReviews);
await frigateApp.goto("/review");
// Wait for any review item to render before firing the shortcut
await frigateApp.page
.locator(".review-item")
.first()
.waitFor({ state: "visible", timeout: 10_000 });
// Ctrl+A triggers onSelectAllReviews (see EventView.tsx useKeyboardListener)
await frigateApp.page.keyboard.press("Control+a");
// The action group should show "51 selected" but no Export button.
// Mark-as-reviewed is still there so the action bar is rendered.
// Scope the "Mark as reviewed" lookup to its exact aria-label because
// the page can render other "mark as reviewed" controls elsewhere
// (e.g. on individual cards) that would trip strict-mode matching.
await expect(frigateApp.page.getByText(/51.*selected/i)).toBeVisible({
timeout: 5_000,
});
await expect(
frigateApp.page.getByRole("button", { name: "Mark as reviewed" }),
).toBeVisible();
await expect(
frigateApp.page.getByRole("button", { name: /^export$/i }),
).toHaveCount(0);
});
test("attaching to an existing case sends export_case_id without new_case_name", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
// Seed one existing case so the dialog can offer the "existing" branch.
// The fixture mocks the user as admin (adminProfile()), so useIsAdmin()
// is true and the dialog renders the "Existing case" radio.
await frigateApp.page.route("**/api/cases", (route) =>
route.fulfill({
json: [
{
id: "existing-case-abc",
name: "Incident #42",
description: "",
created_at: now - 3600,
updated_at: now - 3600,
},
],
}),
);
let capturedPayload: unknown = null;
await frigateApp.page.route("**/api/exports/batch", async (route) => {
capturedPayload = route.request().postDataJSON();
await route.fulfill({
status: 202,
json: {
export_case_id: "existing-case-abc",
export_ids: ["front_door_a", "backyard_b"],
results: [
{
camera: "front_door",
export_id: "front_door_a",
success: true,
status: "queued",
error: null,
item_index: 0,
},
{
camera: "backyard",
export_id: "backyard_b",
success: true,
status: "queued",
error: null,
item_index: 1,
},
],
},
});
});
await frigateApp.goto("/review");
await selectTwoReviews(frigateApp);
await frigateApp.page
.getByRole("button", { name: /export/i })
.first()
.click();
const dialog = frigateApp.page
.getByRole("dialog")
.filter({ hasText: /Export 2 reviews/i });
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Open the Case Select dropdown and pick the seeded case directly.
// The dialog now uses a single Select listing existing cases above
// the "Create new case" option — no radio toggle needed.
const selectTrigger = dialog.locator("button[role='combobox']").first();
await selectTrigger.waitFor({ state: "visible", timeout: 5_000 });
await selectTrigger.click();
// The dropdown portal renders outside the dialog
await frigateApp.page.getByRole("option", { name: /Incident #42/ }).click();
await dialog.getByRole("button", { name: /export 2 reviews/i }).click();
await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull();
const payload = capturedPayload as {
items: unknown[];
new_case_name?: string;
new_case_description?: string;
export_case_id?: string;
};
expect(payload.export_case_id).toBe("existing-case-abc");
expect(payload.new_case_name).toBeUndefined();
expect(payload.new_case_description).toBeUndefined();
expect(payload.items).toHaveLength(2);
// Navigate should hit /export. useSearchEffect consumes the caseId
// query param and strips it once the case is found in the cases list,
// so we assert on the path, not the query string.
await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, {
timeout: 5_000,
});
});
});
+57 -2
View File
@@ -50,24 +50,79 @@
"placeholder": "Name the Export"
},
"case": {
"newCaseOption": "Create new case",
"newCaseNamePlaceholder": "New case name",
"newCaseDescriptionPlaceholder": "Case description",
"label": "Case",
"nonAdminHelp": "A new case will be created for these exports.",
"placeholder": "Select a case"
},
"select": "Select",
"export": "Export",
"queueing": "Queueing Export...",
"selectOrExport": "Select or Export",
"tabs": {
"export": "Single Camera",
"multiCamera": "Multi-Camera"
},
"multiCamera": {
"timeRange": "Time range",
"selectFromTimeline": "Select from Timeline",
"cameraSelection": "Cameras",
"cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected",
"checkingActivity": "Checking camera activity...",
"noCameras": "No cameras available",
"detectionCount_one": "1 tracked object",
"detectionCount_other": "{{count}} tracked objects",
"nameLabel": "Export name",
"namePlaceholder": "Optional base name for these exports",
"queueingButton": "Queueing Exports...",
"exportButton_one": "Export 1 Camera",
"exportButton_other": "Export {{count}} Cameras"
},
"multi": {
"title": "Export {{count}} reviews",
"title_one": "Export 1 review",
"title_other": "Export {{count}} reviews",
"description": "Export each selected review. All exports will be grouped under a single case.",
"descriptionNoCase": "Export each selected review.",
"caseNamePlaceholder": "Review export - {{date}}",
"exportButton": "Export {{count}} reviews",
"exportButton_one": "Export 1 review",
"exportButton_other": "Export {{count}} reviews",
"exportingButton": "Exporting...",
"toast": {
"started_one": "Started 1 export. Opening the case now.",
"started_other": "Started {{count}} exports. Opening the case now.",
"startedNoCase_one": "Started 1 export.",
"startedNoCase_other": "Started {{count}} exports.",
"partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}",
"failed": "Failed to start {{total}} exports. Failed: {{failedItems}}"
}
},
"toast": {
"success": "Successfully started export. View the file in the exports page.",
"queued": "Export queued. View progress in the exports page.",
"view": "View",
"batchSuccess_one": "Started 1 export. Opening the case now.",
"batchSuccess_other": "Started {{count}} exports. Opening the case now.",
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
"batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}",
"batchQueuedSuccess_one": "Queued 1 export. Opening the case now.",
"batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.",
"batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
"batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}",
"error": {
"failed": "Failed to start export: {{error}}",
"failed": "Failed to queue export: {{error}}",
"endTimeMustAfterStartTime": "End time must be after start time",
"noVaildTimeSelected": "No valid time range selected"
}
},
"fromTimeline": {
"saveExport": "Save Export",
"previewExport": "Preview Export"
"queueingExport": "Queueing Export...",
"previewExport": "Preview Export",
"useThisRange": "Use This Range"
}
},
"streaming": {
+86 -2
View File
@@ -20,14 +20,30 @@
"downloadVideo": "Download video",
"editName": "Edit name",
"deleteExport": "Delete export",
"assignToCase": "Add to case"
"assignToCase": "Add to case",
"removeFromCase": "Remove from case"
},
"toolbar": {
"newCase": "New Case",
"addExport": "Add Export",
"editCase": "Edit Case",
"deleteCase": "Delete Case"
},
"toast": {
"error": {
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}",
"caseSaveFailed": "Failed to save case: {{errorMessage}}",
"caseDeleteFailed": "Failed to delete case: {{errorMessage}}"
}
},
"deleteCase": {
"label": "Delete Case",
"desc": "Are you sure you want to delete {{caseName}}?",
"descKeepExports": "Exports will remain available as uncategorized exports.",
"descDeleteExports": "All exports in this case will be permanently deleted.",
"deleteExports": "Also delete exports"
},
"caseDialog": {
"title": "Add to case",
"description": "Choose an existing case or create a new one.",
@@ -35,5 +51,73 @@
"newCaseOption": "Create new case",
"nameLabel": "Case name",
"descriptionLabel": "Description"
},
"caseCard": {
"emptyCase": "No exports yet"
},
"jobCard": {
"defaultName": "{{camera}} export",
"queued": "Queued",
"running": "Running"
},
"caseView": {
"noDescription": "No description",
"createdAt": "Created {{value}}",
"exportCount_one": "1 export",
"exportCount_other": "{{count}} exports",
"cameraCount_one": "1 camera",
"cameraCount_other": "{{count}} cameras",
"showMore": "Show more",
"showLess": "Show less",
"emptyTitle": "This case is empty",
"emptyDescription": "Add existing uncategorized exports to keep the case organized.",
"emptyDescriptionNoExports": "There are no uncategorized exports available to add yet."
},
"caseEditor": {
"createTitle": "Create Case",
"editTitle": "Edit Case",
"namePlaceholder": "Case name",
"descriptionPlaceholder": "Add notes or context for this case"
},
"addExportDialog": {
"title": "Add Export to {{caseName}}",
"searchPlaceholder": "Search uncategorized exports",
"empty": "No uncategorized exports match this search.",
"addButton_one": "Add 1 Export",
"addButton_other": "Add {{count}} Exports",
"adding": "Adding..."
},
"selected_one": "{{count}} selected",
"selected_other": "{{count}} selected",
"bulkActions": {
"addToCase": "Add to Case",
"moveToCase": "Move to Case",
"removeFromCase": "Remove from Case",
"delete": "Delete",
"deleteNow": "Delete Now"
},
"bulkDelete": {
"title": "Delete Exports",
"desc_one": "Are you sure you want to delete {{count}} export?",
"desc_other": "Are you sure you want to delete {{count}} exports?"
},
"bulkRemoveFromCase": {
"title": "Remove from Case",
"desc_one": "Remove {{count}} export from this case?",
"desc_other": "Remove {{count}} exports from this case?",
"descKeepExports": "Exports will be moved to uncategorized.",
"descDeleteExports": "Exports will be permanently deleted.",
"deleteExports": "Delete exports instead"
},
"bulkToast": {
"success": {
"delete": "Successfully deleted exports",
"reassign": "Successfully updated case assignment",
"remove": "Successfully removed exports from case"
},
"error": {
"deleteFailed": "Failed to delete exports: {{errorMessage}}",
"reassignFailed": "Failed to update case assignment: {{errorMessage}}"
}
}
}
+124 -11
View File
@@ -1,6 +1,6 @@
import ActivityIndicator from "../indicators/activity-indicator";
import { Button } from "../ui/button";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { isMobile } from "react-device-detect";
import { FiMoreVertical } from "react-icons/fi";
import { Skeleton } from "../ui/skeleton";
@@ -13,7 +13,7 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { DeleteClipType, Export, ExportCase } from "@/types/export";
import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
@@ -27,7 +27,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { FaFolder } from "react-icons/fa";
import { FaFolder, FaVideo } from "react-icons/fa";
import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import useContextMenu from "@/hooks/use-contextmenu";
type CaseCardProps = {
className: string;
@@ -41,10 +44,15 @@ export function CaseCard({
exports,
onSelect,
}: CaseCardProps) {
const { t } = useTranslation(["views/exports"]);
const firstExport = useMemo(
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
[exports],
);
const cameraCount = useMemo(
() => new Set(exports.map((exp) => exp.camera)).size,
[exports],
);
return (
<div
@@ -61,10 +69,30 @@ export function CaseCard({
alt=""
/>
)}
{!firstExport && (
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-2 left-2 z-20 flex items-center justify-start gap-2 text-white">
<FaFolder />
<div className="capitalize">{exportCase.name}</div>
<div className="absolute right-1 top-1 z-40 flex items-center gap-2 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
<div className="flex items-center gap-1">
<HiSquare2Stack className="size-3" />
<div>{exports.length}</div>
</div>
<div className="flex items-center gap-1">
<FaVideo className="size-3" />
<div>{cameraCount}</div>
</div>
</div>
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
<div className="flex items-center justify-start gap-2">
<FaFolder />
<div className="truncate smart-capitalize">{exportCase.name}</div>
</div>
{exports.length === 0 && (
<div className="mt-1 text-xs text-white/80">
{t("caseCard.emptyCase")}
</div>
)}
</div>
</div>
);
@@ -73,18 +101,26 @@ export function CaseCard({
type ExportCardProps = {
className: string;
exportedRecording: Export;
isSelected?: boolean;
selectionMode?: boolean;
onSelect: (selected: Export) => void;
onContextSelect?: (selected: Export) => void;
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
onRemoveFromCase?: (selected: Export) => void;
};
export function ExportCard({
className,
exportedRecording,
isSelected,
selectionMode,
onSelect,
onContextSelect,
onRename,
onDelete,
onAssignToCase,
onRemoveFromCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin();
@@ -92,6 +128,15 @@ export function ExportCard({
exportedRecording.thumb_path.length > 0,
);
// selection
const cardRef = useRef<HTMLDivElement | null>(null);
useContextMenu(cardRef, () => {
if (!exportedRecording.in_progress && onContextSelect) {
onContextSelect(exportedRecording);
}
});
// editing name
const [editName, setEditName] = useState<{
@@ -180,13 +225,18 @@ export function ExportCard({
</Dialog>
<div
ref={cardRef}
className={cn(
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
className,
)}
onClick={() => {
onClick={(e) => {
if (!exportedRecording.in_progress) {
onSelect(exportedRecording);
if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) {
onContextSelect(exportedRecording);
} else {
onSelect(exportedRecording);
}
}
}}
>
@@ -205,7 +255,7 @@ export function ExportCard({
)}
</>
)}
{!exportedRecording.in_progress && (
{!exportedRecording.in_progress && !selectionMode && (
<div className="absolute bottom-2 right-3 z-40">
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
@@ -254,6 +304,18 @@ export function ExportCard({
{t("tooltip.assignToCase")}
</DropdownMenuItem>
)}
{isAdmin && onRemoveFromCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.removeFromCase")}
onClick={(e) => {
e.stopPropagation();
onRemoveFromCase(exportedRecording);
}}
>
{t("tooltip.removeFromCase")}
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
@@ -292,10 +354,61 @@ export function ExportCard({
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
)}
<ImageShadowOverlay />
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
<div
className={cn(
"pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] md:rounded-2xl",
isSelected
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
/>
<div className="absolute bottom-2 left-3 right-12 z-30 text-white">
<div className="truncate smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
</div>
</div>
</div>
</>
);
}
type ActiveExportJobCardProps = {
className?: string;
job: ExportJob;
};
export function ActiveExportJobCard({
className = "",
job,
}: ActiveExportJobCardProps) {
const { t } = useTranslation(["views/exports", "common"]);
const cameraName = useCameraFriendlyName(job.camera);
const displayName = useMemo(() => {
if (job.name && job.name.length > 0) {
return job.name.replaceAll("_", " ");
}
return t("jobCard.defaultName", {
camera: cameraName,
});
}, [cameraName, job.name, t]);
const statusLabel =
job.status === "queued" ? t("jobCard.queued") : t("jobCard.running");
return (
<div
className={cn(
"relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-dashed border-border bg-secondary/40 md:rounded-2xl",
className,
)}
>
<div className="absolute right-3 top-3 z-30 rounded-full bg-selected/90 px-2 py-1 text-xs text-selected-foreground">
{statusLabel}
</div>
<div className="flex flex-col items-center gap-3 px-6 text-center">
<ActivityIndicator />
<div className="text-sm font-medium text-primary">{displayName}</div>
</div>
</div>
);
}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function ReviewCard({
axios
.post(
`export/${event.camera}/start/${event.start_time + REVIEW_PADDING}/end/${endTime}`,
`export/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${endTime}`,
{ playback: "realtime" },
)
.then((response) => {
@@ -56,6 +56,11 @@ const record: SectionConfigOverrides = {
},
camera: {
restartRequired: [],
hiddenFields: [
"enabled_in_config",
"sync_recordings",
"export.max_concurrent",
],
},
};
@@ -0,0 +1,384 @@
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { Button, buttonVariants } from "../ui/button";
import { isDesktop } from "react-device-detect";
import { HiTrash } from "react-icons/hi";
import { LuFolderPlus, LuFolderX } from "react-icons/lu";
import { Export, ExportCase } from "@/types/export";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import OptionAndInputDialog from "../overlay/dialog/OptionAndInputDialog";
type ExportActionGroupProps = {
selectedExports: Export[];
setSelectedExports: (exports: Export[]) => void;
context: "uncategorized" | "case";
cases?: ExportCase[];
currentCaseId?: string;
mutate: () => void;
};
export default function ExportActionGroup({
selectedExports,
setSelectedExports,
context,
cases,
currentCaseId,
mutate,
}: ExportActionGroupProps) {
const { t } = useTranslation(["views/exports", "common"]);
const isAdmin = useIsAdmin();
const onClearSelected = useCallback(() => {
setSelectedExports([]);
}, [setSelectedExports]);
// ── Delete ──────────────────────────────────────────────────────
const onDelete = useCallback(() => {
const ids = selectedExports.map((e) => e.id);
axios
.post("exports/delete", { ids })
.then((resp) => {
if (resp.status === 200) {
toast.success(t("bulkToast.success.delete"), {
position: "top-center",
});
setSelectedExports([]);
mutate();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("bulkToast.error.deleteFailed", { errorMessage }), {
position: "top-center",
});
});
}, [selectedExports, setSelectedExports, mutate, t]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bypassDialog, setBypassDialog] = useState(false);
useKeyboardListener(["Shift"], (_, modifiers) => {
setBypassDialog(modifiers.shift);
return false;
});
const handleDelete = useCallback(() => {
if (bypassDialog) {
onDelete();
} else {
setDeleteDialogOpen(true);
}
}, [bypassDialog, onDelete]);
// ── Remove from case ────────────────────────────────────────────
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
const [deleteExportsOnRemove, setDeleteExportsOnRemove] = useState(false);
const handleRemoveFromCase = useCallback(() => {
const ids = selectedExports.map((e) => e.id);
const request = deleteExportsOnRemove
? axios.post("exports/delete", { ids })
: axios.post("exports/reassign", { ids, export_case_id: null });
request
.then((resp) => {
if (resp.status === 200) {
toast.success(t("bulkToast.success.remove"), {
position: "top-center",
});
setSelectedExports([]);
mutate();
setRemoveDialogOpen(false);
setDeleteExportsOnRemove(false);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), {
position: "top-center",
});
});
}, [selectedExports, deleteExportsOnRemove, setSelectedExports, mutate, t]);
// ── Case picker ─────────────────────────────────────────────────
const [casePickerOpen, setCasePickerOpen] = useState(false);
const caseOptions = useMemo(
() => [
...(cases ?? [])
.filter((c) => c.id !== currentCaseId)
.map((c) => ({
value: c.id,
label: c.name,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
{
value: "new",
label: t("caseDialog.newCaseOption"),
},
],
[cases, currentCaseId, t],
);
const handleAssignToCase = useCallback(
async (caseId: string) => {
const ids = selectedExports.map((e) => e.id);
try {
await axios.post("exports/reassign", {
ids,
export_case_id: caseId,
});
toast.success(t("bulkToast.success.reassign"), {
position: "top-center",
});
setSelectedExports([]);
mutate();
} catch (error) {
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("bulkToast.error.reassignFailed", { errorMessage }), {
position: "top-center",
});
throw error;
}
},
[selectedExports, setSelectedExports, mutate, t],
);
const handleCreateNewCase = useCallback(
async (name: string, description: string) => {
const ids = selectedExports.map((e) => e.id);
try {
const createResp = await axios.post("cases", { name, description });
const newCaseId: string | undefined = createResp.data?.id;
if (newCaseId) {
await axios.post("exports/reassign", {
ids,
export_case_id: newCaseId,
});
}
toast.success(t("bulkToast.success.reassign"), {
position: "top-center",
});
setSelectedExports([]);
mutate();
} catch (error) {
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("bulkToast.error.reassignFailed", { errorMessage }), {
position: "top-center",
});
throw error;
}
},
[selectedExports, setSelectedExports, mutate, t],
);
return (
<>
{/* Delete confirmation dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("bulkDelete.title")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("bulkDelete.desc", { count: selectedExports.length })}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Remove from case dialog */}
{context === "case" && (
<AlertDialog
open={removeDialogOpen}
onOpenChange={(open) => {
if (!open) {
setRemoveDialogOpen(false);
setDeleteExportsOnRemove(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("bulkRemoveFromCase.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("bulkRemoveFromCase.desc", {
count: selectedExports.length,
})}{" "}
{deleteExportsOnRemove
? t("bulkRemoveFromCase.descDeleteExports")
: t("bulkRemoveFromCase.descKeepExports")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center justify-start gap-6">
<Label
htmlFor="bulk-delete-exports-switch"
className="cursor-pointer text-sm"
>
{t("bulkRemoveFromCase.deleteExports")}
</Label>
<Switch
id="bulk-delete-exports-switch"
checked={deleteExportsOnRemove}
onCheckedChange={setDeleteExportsOnRemove}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleRemoveFromCase}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Case picker dialog */}
<OptionAndInputDialog
open={casePickerOpen}
title={t("caseDialog.title")}
description={t("caseDialog.description")}
setOpen={setCasePickerOpen}
options={caseOptions}
nameLabel={t("caseDialog.nameLabel")}
descriptionLabel={t("caseDialog.descriptionLabel")}
initialValue={caseOptions[0]?.value}
newValueKey="new"
onSave={handleAssignToCase}
onCreateNew={handleCreateNewCase}
/>
{/* Action bar */}
<div className="flex w-full items-center justify-end gap-2">
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
<div className="p-1">
{t("selected", { count: selectedExports.length })}
</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
{t("button.unselect", { ns: "common" })}
</div>
</div>
{isAdmin && (
<div className="flex items-center gap-1 md:gap-2">
{/* Add to Case / Move to Case */}
<Button
className="flex items-center gap-2 p-2"
aria-label={
context === "case"
? t("bulkActions.moveToCase")
: t("bulkActions.addToCase")
}
size="sm"
onClick={() => setCasePickerOpen(true)}
>
<LuFolderPlus className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{context === "case"
? t("bulkActions.moveToCase")
: t("bulkActions.addToCase")}
</div>
)}
</Button>
{/* Remove from Case (case context only) */}
{context === "case" && (
<Button
className="flex items-center gap-2 p-2"
aria-label={t("bulkActions.removeFromCase")}
size="sm"
onClick={() => setRemoveDialogOpen(true)}
>
<LuFolderX className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("bulkActions.removeFromCase")}
</div>
)}
</Button>
)}
{/* Delete */}
<Button
className="flex items-center gap-2 p-2"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog
? t("bulkActions.deleteNow")
: t("bulkActions.delete")}
</div>
)}
</Button>
</div>
)}
</div>
</>
);
}
@@ -6,6 +6,7 @@ import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa";
import { HiTrash } from "react-icons/hi";
import { ReviewSegment } from "@/types/review";
import { MAX_BATCH_EXPORT_ITEMS } from "@/types/export";
import {
AlertDialog,
AlertDialogAction,
@@ -20,6 +21,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import MultiExportDialog from "../overlay/MultiExportDialog";
type ReviewActionGroupProps = {
selectedReviews: ReviewSegment[];
@@ -164,6 +166,29 @@ export default function ReviewActionGroup({
)}
</Button>
)}
{selectedReviews.length >= 2 &&
selectedReviews.length <= MAX_BATCH_EXPORT_ITEMS && (
<MultiExportDialog
selectedReviews={selectedReviews}
onStarted={() => {
onClearSelected();
pullLatestData();
}}
>
<Button
className="flex items-center gap-2 p-2"
aria-label={t("recording.button.export")}
size="sm"
>
<FaCompactDisc className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("recording.button.export")}
</div>
)}
</Button>
</MultiExportDialog>
)}
<Button
className="flex items-center gap-2 p-2"
aria-label={
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@ import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import {
DebugReplayContent,
SaveDebugReplayOverlay,
@@ -26,6 +26,7 @@ import SaveExportOverlay from "./SaveExportOverlay";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { StartExportResponse } from "@/types/export";
type DrawerMode =
| "none"
@@ -102,6 +103,7 @@ export default function MobileReviewSettingsDrawer({
]);
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [exportTab, setExportTab] = useState<ExportTab>("export");
const [selectedReplayOption, setSelectedReplayOption] = useState<
"1" | "5" | "custom" | "timeline"
>("1");
@@ -113,67 +115,112 @@ export default function MobileReviewSettingsDrawer({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined,
);
const onStartExport = useCallback(() => {
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [isStartingExport, setIsStartingExport] = useState(false);
const onStartExport = useCallback(async () => {
if (isStartingExport) {
return false;
}
if (!range) {
toast.error(t("toast.error.noValidTimeSelected"), {
position: "top-center",
});
return;
toast.error(
t("export.toast.error.noVaildTimeSelected", {
ns: "components/dialog",
}),
{
position: "top-center",
},
);
return false;
}
if (range.before < range.after) {
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
position: "top-center",
});
return;
toast.error(
t("export.toast.error.endTimeMustAfterStartTime", {
ns: "components/dialog",
}),
{
position: "top-center",
},
);
return false;
}
axios
.post(
setIsStartingExport(true);
try {
let exportCaseId: string | undefined = selectedCaseId;
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
const caseResp = await axios.post("cases", {
name: singleNewCaseName.trim(),
description: singleNewCaseDescription.trim() || undefined,
});
exportCaseId = caseResp.data?.id;
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
exportCaseId = undefined;
}
await axios.post<StartExportResponse>(
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
{
playback: "realtime",
source: "recordings",
name,
export_case_id: selectedCaseId || undefined,
export_case_id: exportCaseId,
},
)
.then((response) => {
if (response.status == 200) {
toast.success(
t("export.toast.success", { ns: "components/dialog" }),
{
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>
{t("export.toast.view", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
setName("");
setSelectedCaseId(undefined);
setRange(undefined);
setMode("none");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("export.toast.error.failed", {
ns: "components/dialog",
errorMessage,
}),
{
position: "top-center",
},
);
);
toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>
{t("export.toast.view", { ns: "components/dialog" })}
</Button>
</a>
),
});
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
setName("");
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setRange(undefined);
setMode("none");
return true;
} catch (error) {
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("export.toast.error.failed", {
ns: "components/dialog",
error: errorMessage,
}),
{
position: "top-center",
},
);
return false;
} finally {
setIsStartingExport(false);
}
}, [
camera,
isStartingExport,
name,
range,
selectedCaseId,
singleNewCaseDescription,
singleNewCaseName,
setRange,
setMode,
t,
]);
const onStartDebugReplay = useCallback(async () => {
if (
@@ -267,6 +314,7 @@ export default function MobileReviewSettingsDrawer({
className="flex w-full items-center justify-center gap-2"
aria-label={t("export")}
onClick={() => {
setExportTab("export");
setDrawerMode("export");
setMode("select");
}}
@@ -331,14 +379,21 @@ export default function MobileReviewSettingsDrawer({
range={range}
name={name}
selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
activeTab={exportTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
setActiveTab={setExportTab}
setName={setName}
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setRange={setRange}
setMode={(mode) => {
setMode(mode);
if (mode == "timeline") {
if (mode == "timeline" || mode == "timeline_multi") {
setDrawerMode("none");
}
}}
@@ -346,6 +401,9 @@ export default function MobileReviewSettingsDrawer({
setMode("none");
setRange(undefined);
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setExportTab("export");
setDrawerMode("select");
}}
/>
@@ -483,9 +541,29 @@ export default function MobileReviewSettingsDrawer({
<>
<SaveExportOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"}
onSave={() => onStartExport()}
onCancel={() => setMode("none")}
show={mode == "timeline" || mode == "timeline_multi"}
hidePreview={mode == "timeline_multi"}
isSaving={isStartingExport}
saveLabel={
mode == "timeline_multi"
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
: undefined
}
onSave={() => {
if (mode == "timeline_multi") {
setExportTab("multi");
setDrawerMode("export");
setMode("select");
return;
}
void onStartExport();
}}
onCancel={() => {
setExportTab("export");
setRange(undefined);
setMode("none");
}}
onPreview={() => setShowExportPreview(true)}
/>
<SaveDebugReplayOverlay
@@ -0,0 +1,403 @@
import { useCallback, useMemo, useState } from "react";
import { isDesktop } from "react-device-detect";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "../ui/drawer";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Textarea } from "../ui/textarea";
import {
BatchExportBody,
BatchExportResponse,
BatchExportResult,
ExportCase,
} from "@/types/export";
import { FrigateConfig } from "@/types/frigateConfig";
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type MultiExportDialogProps = {
selectedReviews: ReviewSegment[];
onStarted: () => void;
children: React.ReactNode;
};
const NONE_CASE_OPTION = "none";
const NEW_CASE_OPTION = "new";
export default function MultiExportDialog({
selectedReviews,
onStarted,
children,
}: MultiExportDialogProps) {
const { t } = useTranslation(["components/dialog", "common"]);
const locale = useDateLocale();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config");
// Only admins can attach exports to an existing case (enforced server-side
// by POST /exports/batch). Skip fetching the case list entirely for
// non-admins — they can only ever use the "Create new case" branch.
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const [open, setOpen] = useState(false);
const [caseSelection, setCaseSelection] = useState<string>(NONE_CASE_OPTION);
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isExporting, setIsExporting] = useState(false);
const count = selectedReviews.length;
// Resolve a failed batch result back to a human-readable label via the
// client-provided review id when available. Falls back to item_index and
// finally camera name for defensive compatibility.
const formatFailureLabel = useCallback(
(result: BatchExportResult): string => {
const cameraName = resolveCameraName(config, result.camera);
if (result.client_item_id) {
const review = selectedReviews.find(
(item) => item.id === result.client_item_id,
);
if (review) {
const time = formatUnixTimestampToDateTime(review.start_time, {
date_style: "short",
time_style: "short",
locale,
});
return `${cameraName}${time}`;
}
}
if (
typeof result.item_index === "number" &&
result.item_index >= 0 &&
result.item_index < selectedReviews.length
) {
const review = selectedReviews[result.item_index];
const time = formatUnixTimestampToDateTime(review.start_time, {
date_style: "short",
time_style: "short",
locale,
});
return `${cameraName}${time}`;
}
return cameraName;
},
[config, locale, selectedReviews],
);
const defaultCaseName = useMemo(() => {
const formattedDate = formatUnixTimestampToDateTime(Date.now() / 1000, {
date_style: "medium",
time_style: "short",
locale,
});
return t("export.multi.caseNamePlaceholder", {
ns: "components/dialog",
date: formattedDate,
});
}, [t, locale]);
const resetState = useCallback(() => {
setCaseSelection(NONE_CASE_OPTION);
setNewCaseName("");
setNewCaseDescription("");
setIsExporting(false);
}, []);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
resetState();
} else {
// Freshly reset each time so the default name reflects "now"
setCaseSelection(NONE_CASE_OPTION);
setNewCaseName(defaultCaseName);
setNewCaseDescription("");
setIsExporting(false);
}
setOpen(next);
},
[defaultCaseName, resetState],
);
const existingCases = useMemo(() => {
return (cases ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
}, [cases]);
const isNewCase = caseSelection === NEW_CASE_OPTION;
const canSubmit = useMemo(() => {
if (isExporting) return false;
if (count === 0) return false;
if (!isAdmin) return true;
if (isNewCase) {
return newCaseName.trim().length > 0;
}
return caseSelection.length > 0;
}, [caseSelection, count, isAdmin, isExporting, isNewCase, newCaseName]);
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
const items = selectedReviews.map((review) => ({
camera: review.camera,
start_time: review.start_time - REVIEW_PADDING,
end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
image_path: review.thumb_path || undefined,
client_item_id: review.id,
}));
const payload: BatchExportBody = { items };
if (isAdmin && caseSelection !== NONE_CASE_OPTION) {
if (isNewCase) {
payload.new_case_name = newCaseName.trim();
payload.new_case_description = newCaseDescription.trim() || undefined;
} else {
payload.export_case_id = caseSelection;
}
}
setIsExporting(true);
try {
const response = await axios.post<BatchExportResponse>(
"exports/batch",
payload,
);
const results = response.data.results ?? [];
const successful = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
if (successful.length > 0 && failed.length === 0) {
toast.success(
t(
isAdmin
? "export.multi.toast.started"
: "export.multi.toast.startedNoCase",
{
ns: "components/dialog",
count: successful.length,
},
),
{ position: "top-center" },
);
} else if (successful.length > 0 && failed.length > 0) {
// Resolve each failure to its review via item_index so same-camera
// items are disambiguated by time. Falls back to camera-only if the
// server didn't populate item_index.
const failedLabels = failed.map(formatFailureLabel).join(", ");
toast.success(
t("export.multi.toast.partial", {
ns: "components/dialog",
successful: successful.length,
total: results.length,
failedItems: failedLabels,
}),
{ position: "top-center" },
);
} else {
const failedLabels = failed.map(formatFailureLabel).join(", ");
toast.error(
t("export.multi.toast.failed", {
ns: "components/dialog",
total: results.length,
failedItems: failedLabels,
}),
{ position: "top-center" },
);
}
if (successful.length > 0) {
onStarted();
setOpen(false);
resetState();
if (response.data.export_case_id) {
navigate(`/export?caseId=${response.data.export_case_id}`);
}
}
} catch (error) {
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("export.toast.error.failed", {
ns: "components/dialog",
error: errorMessage,
}),
{ position: "top-center" },
);
} finally {
setIsExporting(false);
}
}, [
canSubmit,
caseSelection,
formatFailureLabel,
isAdmin,
isNewCase,
navigate,
newCaseDescription,
newCaseName,
onStarted,
resetState,
selectedReviews,
t,
]);
// New-case inputs: rendered below the Select when caseSelection === "new",
// or rendered standalone for non-admins (who never see the Select since
// they cannot attach to an existing case).
const newCaseInputs = (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
maxLength={100}
autoFocus={isDesktop}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) => setNewCaseDescription(event.target.value)}
rows={2}
/>
</div>
);
const body = (
<div className="flex flex-col gap-4">
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
</Label>
<Select
value={caseSelection}
onValueChange={(value) => setCaseSelection(value)}
>
<SelectTrigger>
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_CASE_OPTION}>
{t("label.none", { ns: "common" })}
</SelectItem>
{existingCases.map((caseItem) => (
<SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={NEW_CASE_OPTION}>
{t("export.case.newCaseOption")}
</SelectItem>
</SelectContent>
</Select>
{isNewCase && newCaseInputs}
</div>
)}
</div>
);
const footer = (
<>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isExporting}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
onClick={handleSubmit}
disabled={!canSubmit}
aria-label={t("export.multi.exportButton", { count })}
>
{isExporting
? t("export.multi.exportingButton")
: t("export.multi.exportButton", { count })}
</Button>
</>
);
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("export.multi.title", { count })}</DialogTitle>
<DialogDescription>
{isAdmin
? t("export.multi.description")
: t("export.multi.descriptionNoCase")}
</DialogDescription>
</DialogHeader>
{body}
<DialogFooter className="gap-2">{footer}</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent className="px-4 pb-6">
<DrawerHeader className="px-0">
<DrawerTitle>{t("export.multi.title", { count })}</DrawerTitle>
<DrawerDescription>
{isAdmin
? t("export.multi.description")
: t("export.multi.descriptionNoCase")}
</DrawerDescription>
</DrawerHeader>
{body}
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
</DrawerContent>
</Drawer>
);
}
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
type SaveExportOverlayProps = {
className: string;
show: boolean;
hidePreview?: boolean;
saveLabel?: string;
isSaving?: boolean;
onPreview: () => void;
onSave: () => void;
onCancel: () => void;
@@ -14,6 +17,9 @@ type SaveExportOverlayProps = {
export default function SaveExportOverlay({
className,
show,
hidePreview = false,
saveLabel,
isSaving = false,
onPreview,
onSave,
onCancel,
@@ -32,29 +38,36 @@ export default function SaveExportOverlay({
className="flex items-center gap-1 text-primary"
aria-label={t("button.cancel", { ns: "common" })}
size="sm"
disabled={isSaving}
onClick={onCancel}
>
<LuX />
{t("button.cancel", { ns: "common" })}
</Button>
{!hidePreview && (
<Button
className="flex items-center gap-1"
aria-label={t("export.fromTimeline.previewExport")}
size="sm"
disabled={isSaving}
onClick={onPreview}
>
<LuVideo />
{t("export.fromTimeline.previewExport")}
</Button>
)}
<Button
className="flex items-center gap-1"
aria-label={t("export.fromTimeline.previewExport")}
size="sm"
onClick={onPreview}
>
<LuVideo />
{t("export.fromTimeline.previewExport")}
</Button>
<Button
className="flex items-center gap-1"
aria-label={t("export.fromTimeline.saveExport")}
aria-label={saveLabel || t("export.fromTimeline.saveExport")}
variant="select"
size="sm"
disabled={isSaving}
onClick={onSave}
>
<FaCompactDisc />
{t("export.fromTimeline.saveExport")}
{isSaving
? t("export.fromTimeline.queueingExport")
: saveLabel || t("export.fromTimeline.saveExport")}
</Button>
</div>
</div>
@@ -8,6 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -15,9 +16,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type Option = {
@@ -35,8 +37,8 @@ type OptionAndInputDialogProps = {
nameLabel: string;
descriptionLabel: string;
setOpen: (open: boolean) => void;
onSave: (value: string) => void;
onCreateNew: (name: string, description: string) => void;
onSave: (value: string) => Promise<void>;
onCreateNew: (name: string, description: string) => Promise<void>;
};
export default function OptionAndInputDialog({
@@ -69,10 +71,12 @@ export default function OptionAndInputDialog({
}
}, [open, initialValue, firstOption]);
const [isLoading, setIsLoading] = useState(false);
const isNew = selectedValue === newValueKey;
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
const disableSave =
!selectedValue || (isNew && name.trim().length === 0) || isLoading;
const handleSave = () => {
const handleSave = useCallback(async () => {
if (!selectedValue) {
return;
}
@@ -80,13 +84,26 @@ export default function OptionAndInputDialog({
const trimmedName = name.trim();
const trimmedDescription = descriptionValue.trim();
if (isNew) {
onCreateNew(trimmedName, trimmedDescription);
} else {
onSave(selectedValue);
setIsLoading(true);
try {
if (isNew) {
await onCreateNew(trimmedName, trimmedDescription);
} else {
await onSave(selectedValue);
}
setOpen(false);
} finally {
setIsLoading(false);
}
setOpen(false);
};
}, [
selectedValue,
name,
descriptionValue,
isNew,
onCreateNew,
onSave,
setOpen,
]);
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
@@ -127,15 +144,21 @@ export default function OptionAndInputDialog({
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input
className="text-md"
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
<Textarea
className="text-md"
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
rows={2}
/>
</div>
</div>
@@ -145,6 +168,7 @@ export default function OptionAndInputDialog({
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
setOpen(false);
}}
@@ -155,9 +179,13 @@ export default function OptionAndInputDialog({
type="button"
variant="select"
disabled={disableSave}
onClick={handleSave}
onClick={() => void handleSave()}
>
{t("button.save")}
{isLoading ? (
<ActivityIndicator className="size-4" />
) : (
t("button.save")
)}
</Button>
</DialogFooter>
</DialogContent>
+1 -1
View File
@@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
closeButton:
"group-[.toast]:bg-secondary border-primary border-[1px]",
"group-[.toast]:bg-secondary group-[.toast]:text-primary group-[.toast]:border-primary group-[.toast]:border-[1px]",
success:
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
error:
+875 -75
View File
File diff suppressed because it is too large Load Diff
+77 -1
View File
@@ -6,7 +6,8 @@ export type Export = {
video_path: string;
thumb_path: string;
in_progress: boolean;
export_case?: string;
export_case?: string | null;
export_case_id?: string | null;
};
export type ExportCase = {
@@ -17,6 +18,81 @@ export type ExportCase = {
updated_at: number;
};
export type BatchExportBody = {
items: BatchExportItem[];
export_case_id?: string;
new_case_name?: string;
new_case_description?: string;
};
export const MAX_BATCH_EXPORT_ITEMS = 50;
export type BatchExportItem = {
camera: string;
start_time: number;
end_time: number;
image_path?: string;
friendly_name?: string;
client_item_id?: string;
};
export type BatchExportResult = {
camera: string;
export_id?: string | null;
success: boolean;
status?: string | null;
error?: string | null;
item_index?: number | null;
client_item_id?: string | null;
};
export type BatchExportResponse = {
export_case_id?: string | null;
export_ids: string[];
results: BatchExportResult[];
};
export type StartExportResponse = {
success: boolean;
message: string;
export_id?: string | null;
status?: string | null;
};
export type ExportJob = {
id: string;
job_type: string;
status: string;
camera: string;
name?: string | null;
export_case_id?: string | null;
request_start_time: number;
request_end_time: number;
start_time?: number | null;
end_time?: number | null;
error_message?: string | null;
results?: {
export_id?: string;
export_case_id?: string | null;
video_path?: string;
thumb_path?: string;
} | null;
};
export type CameraActivitySegment = {
/** Fractional start position within the time range, 0-1 inclusive. */
start: number;
/** Fractional end position within the time range, 0-1 inclusive. */
end: number;
};
export type CameraActivity = {
camera: string;
count: number;
hasDetections: boolean;
segments: CameraActivitySegment[];
};
export type DeleteClipType = {
file: string;
exportName: string;
+1 -1
View File
@@ -2,7 +2,7 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FilterType = { [searchKey: string]: any };
export type ExportMode = "select" | "timeline" | "none";
export type ExportMode = "select" | "timeline" | "timeline_multi" | "none";
export type FilterList = {
labels?: string[];
@@ -270,7 +270,10 @@ export default function MotionSearchView({
);
useEffect(() => {
if (exportMode !== "timeline" || exportRange) {
if (
(exportMode !== "timeline" && exportMode !== "timeline_multi") ||
exportRange
) {
return;
}
@@ -955,9 +958,25 @@ export default function MotionSearchView({
<SaveExportOverlay
className="pointer-events-none absolute inset-x-0 top-0 z-30"
show={exportMode === "timeline" && Boolean(exportRange)}
show={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
}
hidePreview={exportMode === "timeline_multi"}
saveLabel={
exportMode === "timeline_multi"
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
: undefined
}
onPreview={handleExportPreview}
onSave={handleExportSave}
onSave={() => {
if (exportMode === "timeline_multi") {
setExportMode("select");
return;
}
handleExportSave();
}}
onCancel={handleExportCancel}
/>
@@ -976,7 +995,10 @@ export default function MotionSearchView({
noRecordingRanges={noRecordings ?? []}
contentRef={contentRef}
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
showExportHandles={exportMode === "timeline" && Boolean(exportRange)}
showExportHandles={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
}
exportStartTime={exportRange?.after}
exportEndTime={exportRange?.before}
setExportStartTime={setExportStartTime}
@@ -1408,7 +1430,11 @@ export default function MotionSearchView({
onControllerReady={(controller) => {
mainControllerRef.current = controller;
}}
isScrubbing={scrubbing || exportMode == "timeline"}
isScrubbing={
scrubbing ||
exportMode == "timeline" ||
exportMode == "timeline_multi"
}
supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen}
+2 -1
View File
@@ -833,6 +833,7 @@ export function RecordingView({
isScrubbing={
scrubbing ||
exportMode == "timeline" ||
exportMode == "timeline_multi" ||
debugReplayMode == "timeline"
}
supportsFullscreen={supportsFullScreen}
@@ -911,7 +912,7 @@ export function RecordingView({
activeReviewItem={activeReviewItem}
currentTime={currentTime}
exportRange={
exportMode == "timeline"
exportMode == "timeline" || exportMode == "timeline_multi"
? exportRange
: debugReplayMode == "timeline"
? debugReplayRange