mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-02 03:27:41 +03:00
Export improvements (#22867)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* 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:
parent
18c068a3f9
commit
e7e6f87682
362
docs/static/frigate-api.yaml
vendored
362
docs/static/frigate-api.yaml
vendored
@ -2724,6 +2724,135 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/HTTPValidationError"
|
$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:
|
/cases:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -2853,39 +2982,6 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/HTTPValidationError"
|
$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}":
|
"/export/{camera_name}/start/{start_time}/end/{end_time}":
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@ -2973,32 +3069,6 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/HTTPValidationError"
|
$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}":
|
"/export/custom/{camera_name}/start/{start_time}/end/{end_time}":
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@ -6501,6 +6571,149 @@ components:
|
|||||||
required:
|
required:
|
||||||
- recognizedLicensePlate
|
- recognizedLicensePlate
|
||||||
title: EventsLPRBody
|
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:
|
EventsSubLabelBody:
|
||||||
properties:
|
properties:
|
||||||
subLabel:
|
subLabel:
|
||||||
@ -6523,18 +6736,41 @@ components:
|
|||||||
required:
|
required:
|
||||||
- subLabel
|
- subLabel
|
||||||
title: EventsSubLabelBody
|
title: EventsSubLabelBody
|
||||||
ExportCaseAssignBody:
|
ExportBulkDeleteBody:
|
||||||
properties:
|
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:
|
export_case_id:
|
||||||
anyOf:
|
anyOf:
|
||||||
- type: string
|
- type: string
|
||||||
maxLength: 30
|
maxLength: 30
|
||||||
- type: "null"
|
- type: "null"
|
||||||
title: Export Case Id
|
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
|
type: object
|
||||||
title: ExportCaseAssignBody
|
required:
|
||||||
description: Request body for assigning or unassigning an export to a case.
|
- ids
|
||||||
|
title: ExportBulkReassignBody
|
||||||
|
description: Request body for bulk reassigning exports to a case.
|
||||||
ExportCaseCreateBody:
|
ExportCaseCreateBody:
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
|
|||||||
@ -88,7 +88,9 @@ def require_admin_by_default():
|
|||||||
"/go2rtc/streams",
|
"/go2rtc/streams",
|
||||||
"/event_ids",
|
"/event_ids",
|
||||||
"/events",
|
"/events",
|
||||||
|
"/cases",
|
||||||
"/exports",
|
"/exports",
|
||||||
|
"/jobs/export",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Path prefixes that should be exempt (for paths with parameters)
|
# Path prefixes that should be exempt (for paths with parameters)
|
||||||
@ -101,7 +103,9 @@ def require_admin_by_default():
|
|||||||
"/go2rtc/streams/", # /go2rtc/streams/{camera}
|
"/go2rtc/streams/", # /go2rtc/streams/{camera}
|
||||||
"/users/", # /users/{username}/password (has own auth)
|
"/users/", # /users/{username}/password (has own auth)
|
||||||
"/preview/", # /preview/{file}/thumbnail.jpg
|
"/preview/", # /preview/{file}/thumbnail.jpg
|
||||||
|
"/cases/", # /cases/{case_id}
|
||||||
"/exports/", # /exports/{export_id}
|
"/exports/", # /exports/{export_id}
|
||||||
|
"/jobs/export/", # /jobs/export/{export_id}
|
||||||
"/vod/", # /vod/{camera_name}/...
|
"/vod/", # /vod/{camera_name}/...
|
||||||
"/notifications/", # /notifications/pubkey, /notifications/register
|
"/notifications/", # /notifications/pubkey, /notifications/register
|
||||||
)
|
)
|
||||||
|
|||||||
65
frigate/api/defs/request/batch_export_body.py
Normal file
65
frigate/api/defs/request/batch_export_body.py
Normal file
@ -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
|
||||||
24
frigate/api/defs/request/export_bulk_body.py
Normal file
24
frigate/api/defs/request/export_bulk_body.py
Normal file
@ -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(
|
description: Optional[str] = Field(
|
||||||
default=None, description="Updated description of the export case"
|
default=None, description="Updated description of the export case"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExportCaseAssignBody(BaseModel):
|
|
||||||
"""Request body for assigning or unassigning an export to a case."""
|
|
||||||
|
|
||||||
export_case_id: Optional[str] = Field(
|
|
||||||
default=None,
|
|
||||||
max_length=30,
|
|
||||||
description="Case ID to assign to the export, or null to unassign",
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from typing import List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@ -28,6 +28,88 @@ class StartExportResponse(BaseModel):
|
|||||||
export_id: Optional[str] = Field(
|
export_id: Optional[str] = Field(
|
||||||
default=None, description="The export ID if successfully started"
|
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]
|
ExportsResponse = List[ExportModel]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -52,6 +52,7 @@ from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
|||||||
from frigate.events.audio import AudioProcessor
|
from frigate.events.audio import AudioProcessor
|
||||||
from frigate.events.cleanup import EventCleanup
|
from frigate.events.cleanup import EventCleanup
|
||||||
from frigate.events.maintainer import EventProcessor
|
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.jobs.motion_search import stop_all_motion_search_jobs
|
||||||
from frigate.log import _stop_logging
|
from frigate.log import _stop_logging
|
||||||
from frigate.models import (
|
from frigate.models import (
|
||||||
@ -611,6 +612,11 @@ class FrigateApp:
|
|||||||
# Clean up any stale replay camera artifacts (filesystem + DB)
|
# Clean up any stale replay camera artifacts (filesystem + DB)
|
||||||
cleanup_replay_cameras()
|
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.init_inter_process_communicator()
|
||||||
self.start_detectors()
|
self.start_detectors()
|
||||||
self.init_dispatcher()
|
self.init_dispatcher()
|
||||||
|
|||||||
@ -92,6 +92,12 @@ class RecordExportConfig(FrigateBaseModel):
|
|||||||
title="Export hwaccel args",
|
title="Export hwaccel args",
|
||||||
description="Hardware acceleration args to use for export/transcode operations.",
|
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):
|
class RecordConfig(FrigateBaseModel):
|
||||||
|
|||||||
387
frigate/jobs/export.py
Normal file
387
frigate/jobs/export.py
Normal 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()
|
||||||
1433
frigate/test/http_api/test_http_export.py
Normal file
1433
frigate/test/http_api/test_http_export.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -82,14 +82,26 @@ export class ApiMocker {
|
|||||||
route.fulfill({ json: stats }),
|
route.fulfill({ json: stats }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reviews
|
// Reviews. The real backend exposes /review (singular) for the main
|
||||||
await this.page.route("**/api/reviews**", (route) => {
|
// list and /review/summary for the summary — the previous plural glob
|
||||||
const url = route.request().url();
|
// (**/api/reviews**) never matched either endpoint, so review-dependent
|
||||||
if (url.includes("summary")) {
|
// tests silently ran without data. The POST mutations at /reviews/viewed
|
||||||
return route.fulfill({ json: reviewSummary });
|
// and /reviews/delete (plural) still fall through to the generic
|
||||||
}
|
// mutation catch-all further down the file.
|
||||||
return route.fulfill({ json: reviews });
|
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
|
// Recordings summary
|
||||||
await this.page.route("**/api/recordings/summary**", (route) =>
|
await this.page.route("**/api/recordings/summary**", (route) =>
|
||||||
|
|||||||
@ -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";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
|
||||||
test.describe("Export Page - Cards @high", () => {
|
test.describe("Export Page - Overview @high", () => {
|
||||||
test("export page renders export cards from mock data", async ({
|
test("renders uncategorized exports and case cards from mock data", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/export");
|
await frigateApp.goto("/export");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
// Should show export names from our mock data
|
|
||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.getByText("Front Door - Person Alert"),
|
frigateApp.page.getByText("Front Door - Person Alert"),
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.getByText("Backyard - Car Detection"),
|
frigateApp.page.getByText("Garage - In Progress"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText("Package Theft Investigation"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("export page shows in-progress indicator", async ({ frigateApp }) => {
|
test("search filters uncategorized exports", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/export");
|
await frigateApp.goto("/export");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
// "Garage - In Progress" export should be visible
|
const searchInput = frigateApp.page.getByPlaceholder(/search/i).first();
|
||||||
await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible(
|
await searchInput.fill("Front Door");
|
||||||
{ timeout: 10_000 },
|
|
||||||
);
|
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.goto("/export");
|
||||||
await frigateApp.page.waitForTimeout(3000);
|
|
||||||
// Cases may render differently depending on API response shape
|
await frigateApp.page.getByRole("button", { name: "New Case" }).click();
|
||||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
|
||||||
expect(pageText?.length).toBeGreaterThan(0);
|
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.describe("Export Page - Case Detail @high", () => {
|
||||||
test("search input filters export list", async ({ frigateApp }) => {
|
test("opening a case shows its detail view and associated export", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
await frigateApp.goto("/export");
|
await frigateApp.goto("/export");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
const searchInput = frigateApp.page.locator(
|
await frigateApp.page
|
||||||
'#pageRoot input[type="text"], #pageRoot input',
|
.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 expect(dialog.locator("textarea")).toHaveValue(
|
||||||
(await searchInput.count()) > 0 &&
|
"Review of suspicious activity near the front porch",
|
||||||
(await searchInput.first().isVisible())
|
);
|
||||||
) {
|
});
|
||||||
// Type a search term that matches one export
|
|
||||||
await searchInput.first().fill("Front Door");
|
test("add export shows completed uncategorized exports for assignment", async ({
|
||||||
await frigateApp.page.waitForTimeout(500);
|
frigateApp,
|
||||||
// "Front Door - Person Alert" should still be visible
|
}) => {
|
||||||
await expect(
|
await frigateApp.goto("/export");
|
||||||
frigateApp.page.getByText("Front Door - Person Alert"),
|
|
||||||
).toBeVisible();
|
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", () => {
|
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
|
||||||
test("export page filter controls are present", async ({ frigateApp }) => {
|
timeout: 5_000,
|
||||||
await frigateApp.goto("/export");
|
});
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
});
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
|
||||||
const count = await buttons.count();
|
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
|
||||||
expect(count).toBeGreaterThan(0);
|
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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -50,24 +50,79 @@
|
|||||||
"placeholder": "Name the Export"
|
"placeholder": "Name the Export"
|
||||||
},
|
},
|
||||||
"case": {
|
"case": {
|
||||||
|
"newCaseOption": "Create new case",
|
||||||
|
"newCaseNamePlaceholder": "New case name",
|
||||||
|
"newCaseDescriptionPlaceholder": "Case description",
|
||||||
"label": "Case",
|
"label": "Case",
|
||||||
|
"nonAdminHelp": "A new case will be created for these exports.",
|
||||||
"placeholder": "Select a case"
|
"placeholder": "Select a case"
|
||||||
},
|
},
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
|
"queueing": "Queueing Export...",
|
||||||
"selectOrExport": "Select or 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": {
|
"toast": {
|
||||||
"success": "Successfully started export. View the file in the exports page.",
|
"success": "Successfully started export. View the file in the exports page.",
|
||||||
|
"queued": "Export queued. View progress in the exports page.",
|
||||||
"view": "View",
|
"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": {
|
"error": {
|
||||||
"failed": "Failed to start export: {{error}}",
|
"failed": "Failed to queue export: {{error}}",
|
||||||
"endTimeMustAfterStartTime": "End time must be after start time",
|
"endTimeMustAfterStartTime": "End time must be after start time",
|
||||||
"noVaildTimeSelected": "No valid time range selected"
|
"noVaildTimeSelected": "No valid time range selected"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Save Export",
|
"saveExport": "Save Export",
|
||||||
"previewExport": "Preview Export"
|
"queueingExport": "Queueing Export...",
|
||||||
|
"previewExport": "Preview Export",
|
||||||
|
"useThisRange": "Use This Range"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
|
|||||||
@ -20,14 +20,30 @@
|
|||||||
"downloadVideo": "Download video",
|
"downloadVideo": "Download video",
|
||||||
"editName": "Edit name",
|
"editName": "Edit name",
|
||||||
"deleteExport": "Delete export",
|
"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": {
|
"toast": {
|
||||||
"error": {
|
"error": {
|
||||||
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
|
"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": {
|
"caseDialog": {
|
||||||
"title": "Add to case",
|
"title": "Add to case",
|
||||||
"description": "Choose an existing case or create a new one.",
|
"description": "Choose an existing case or create a new one.",
|
||||||
@ -35,5 +51,73 @@
|
|||||||
"newCaseOption": "Create new case",
|
"newCaseOption": "Create new case",
|
||||||
"nameLabel": "Case name",
|
"nameLabel": "Case name",
|
||||||
"descriptionLabel": "Description"
|
"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}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { Button } from "../ui/button";
|
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 { isMobile } from "react-device-detect";
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
@ -13,7 +13,7 @@ import {
|
|||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { shareOrCopy } from "@/utils/browserUtil";
|
import { shareOrCopy } from "@/utils/browserUtil";
|
||||||
@ -27,7 +27,10 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} 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 = {
|
type CaseCardProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@ -41,10 +44,15 @@ export function CaseCard({
|
|||||||
exports,
|
exports,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: CaseCardProps) {
|
}: CaseCardProps) {
|
||||||
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const firstExport = useMemo(
|
const firstExport = useMemo(
|
||||||
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
|
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
|
||||||
[exports],
|
[exports],
|
||||||
);
|
);
|
||||||
|
const cameraCount = useMemo(
|
||||||
|
() => new Set(exports.map((exp) => exp.camera)).size,
|
||||||
|
[exports],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -61,10 +69,30 @@ export function CaseCard({
|
|||||||
alt=""
|
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="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">
|
<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">
|
||||||
<FaFolder />
|
<div className="flex items-center gap-1">
|
||||||
<div className="capitalize">{exportCase.name}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -73,18 +101,26 @@ export function CaseCard({
|
|||||||
type ExportCardProps = {
|
type ExportCardProps = {
|
||||||
className: string;
|
className: string;
|
||||||
exportedRecording: Export;
|
exportedRecording: Export;
|
||||||
|
isSelected?: boolean;
|
||||||
|
selectionMode?: boolean;
|
||||||
onSelect: (selected: Export) => void;
|
onSelect: (selected: Export) => void;
|
||||||
|
onContextSelect?: (selected: Export) => void;
|
||||||
onRename: (original: string, update: string) => void;
|
onRename: (original: string, update: string) => void;
|
||||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||||
onAssignToCase?: (selected: Export) => void;
|
onAssignToCase?: (selected: Export) => void;
|
||||||
|
onRemoveFromCase?: (selected: Export) => void;
|
||||||
};
|
};
|
||||||
export function ExportCard({
|
export function ExportCard({
|
||||||
className,
|
className,
|
||||||
exportedRecording,
|
exportedRecording,
|
||||||
|
isSelected,
|
||||||
|
selectionMode,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onContextSelect,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
onAssignToCase,
|
onAssignToCase,
|
||||||
|
onRemoveFromCase,
|
||||||
}: ExportCardProps) {
|
}: ExportCardProps) {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
@ -92,6 +128,15 @@ export function ExportCard({
|
|||||||
exportedRecording.thumb_path.length > 0,
|
exportedRecording.thumb_path.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// selection
|
||||||
|
|
||||||
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useContextMenu(cardRef, () => {
|
||||||
|
if (!exportedRecording.in_progress && onContextSelect) {
|
||||||
|
onContextSelect(exportedRecording);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// editing name
|
// editing name
|
||||||
|
|
||||||
const [editName, setEditName] = useState<{
|
const [editName, setEditName] = useState<{
|
||||||
@ -180,13 +225,18 @@ export function ExportCard({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={cardRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
if (!exportedRecording.in_progress) {
|
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">
|
<div className="absolute bottom-2 right-3 z-40">
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
@ -254,6 +304,18 @@ export function ExportCard({
|
|||||||
{t("tooltip.assignToCase")}
|
{t("tooltip.assignToCase")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && onRemoveFromCase && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label={t("tooltip.removeFromCase")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveFromCase(exportedRecording);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tooltip.removeFromCase")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
@ -292,10 +354,61 @@ export function ExportCard({
|
|||||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||||
)}
|
)}
|
||||||
<ImageShadowOverlay />
|
<ImageShadowOverlay />
|
||||||
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize">
|
<div
|
||||||
{exportedRecording.name.replaceAll("_", " ")}
|
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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -81,7 +81,7 @@ export default function ReviewCard({
|
|||||||
|
|
||||||
axios
|
axios
|
||||||
.post(
|
.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" },
|
{ playback: "realtime" },
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
|||||||
@ -56,6 +56,11 @@ const record: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
|
hiddenFields: [
|
||||||
|
"enabled_in_config",
|
||||||
|
"sync_recordings",
|
||||||
|
"export.max_concurrent",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
384
web/src/components/filter/ExportActionGroup.tsx
Normal file
384
web/src/components/filter/ExportActionGroup.tsx
Normal file
@ -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 { FaCompactDisc } from "react-icons/fa";
|
||||||
import { HiTrash } from "react-icons/hi";
|
import { HiTrash } from "react-icons/hi";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
|
import { MAX_BATCH_EXPORT_ITEMS } from "@/types/export";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -20,6 +21,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import MultiExportDialog from "../overlay/MultiExportDialog";
|
||||||
|
|
||||||
type ReviewActionGroupProps = {
|
type ReviewActionGroupProps = {
|
||||||
selectedReviews: ReviewSegment[];
|
selectedReviews: ReviewSegment[];
|
||||||
@ -164,6 +166,29 @@ export default function ReviewActionGroup({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
className="flex items-center gap-2 p-2"
|
className="flex items-center gap-2 p-2"
|
||||||
aria-label={
|
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 { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
||||||
import { LuBug } from "react-icons/lu";
|
import { LuBug } from "react-icons/lu";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
|
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
|
||||||
import {
|
import {
|
||||||
DebugReplayContent,
|
DebugReplayContent,
|
||||||
SaveDebugReplayOverlay,
|
SaveDebugReplayOverlay,
|
||||||
@ -26,6 +26,7 @@ import SaveExportOverlay from "./SaveExportOverlay";
|
|||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { StartExportResponse } from "@/types/export";
|
||||||
|
|
||||||
type DrawerMode =
|
type DrawerMode =
|
||||||
| "none"
|
| "none"
|
||||||
@ -102,6 +103,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
]);
|
]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||||
|
const [exportTab, setExportTab] = useState<ExportTab>("export");
|
||||||
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
||||||
"1" | "5" | "custom" | "timeline"
|
"1" | "5" | "custom" | "timeline"
|
||||||
>("1");
|
>("1");
|
||||||
@ -113,67 +115,112 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
||||||
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) {
|
if (!range) {
|
||||||
toast.error(t("toast.error.noValidTimeSelected"), {
|
toast.error(
|
||||||
position: "top-center",
|
t("export.toast.error.noVaildTimeSelected", {
|
||||||
});
|
ns: "components/dialog",
|
||||||
return;
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (range.before < range.after) {
|
if (range.before < range.after) {
|
||||||
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
|
toast.error(
|
||||||
position: "top-center",
|
t("export.toast.error.endTimeMustAfterStartTime", {
|
||||||
});
|
ns: "components/dialog",
|
||||||
return;
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
axios
|
setIsStartingExport(true);
|
||||||
.post(
|
|
||||||
|
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)}`,
|
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||||
{
|
{
|
||||||
playback: "realtime",
|
source: "recordings",
|
||||||
name,
|
name,
|
||||||
export_case_id: selectedCaseId || undefined,
|
export_case_id: exportCaseId,
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.then((response) => {
|
|
||||||
if (response.status == 200) {
|
toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
|
||||||
toast.success(
|
position: "top-center",
|
||||||
t("export.toast.success", { ns: "components/dialog" }),
|
action: (
|
||||||
{
|
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||||
position: "top-center",
|
<Button>
|
||||||
action: (
|
{t("export.toast.view", { ns: "components/dialog" })}
|
||||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
</Button>
|
||||||
<Button>
|
</a>
|
||||||
{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",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, [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 () => {
|
const onStartDebugReplay = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
@ -267,6 +314,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
className="flex w-full items-center justify-center gap-2"
|
className="flex w-full items-center justify-center gap-2"
|
||||||
aria-label={t("export")}
|
aria-label={t("export")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setExportTab("export");
|
||||||
setDrawerMode("export");
|
setDrawerMode("export");
|
||||||
setMode("select");
|
setMode("select");
|
||||||
}}
|
}}
|
||||||
@ -331,14 +379,21 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
range={range}
|
range={range}
|
||||||
name={name}
|
name={name}
|
||||||
selectedCaseId={selectedCaseId}
|
selectedCaseId={selectedCaseId}
|
||||||
|
singleNewCaseName={singleNewCaseName}
|
||||||
|
singleNewCaseDescription={singleNewCaseDescription}
|
||||||
|
activeTab={exportTab}
|
||||||
|
isStartingExport={isStartingExport}
|
||||||
onStartExport={onStartExport}
|
onStartExport={onStartExport}
|
||||||
|
setActiveTab={setExportTab}
|
||||||
setName={setName}
|
setName={setName}
|
||||||
setSelectedCaseId={setSelectedCaseId}
|
setSelectedCaseId={setSelectedCaseId}
|
||||||
|
setSingleNewCaseName={setSingleNewCaseName}
|
||||||
|
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
||||||
setRange={setRange}
|
setRange={setRange}
|
||||||
setMode={(mode) => {
|
setMode={(mode) => {
|
||||||
setMode(mode);
|
setMode(mode);
|
||||||
|
|
||||||
if (mode == "timeline") {
|
if (mode == "timeline" || mode == "timeline_multi") {
|
||||||
setDrawerMode("none");
|
setDrawerMode("none");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -346,6 +401,9 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
setMode("none");
|
setMode("none");
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
|
setSingleNewCaseName("");
|
||||||
|
setSingleNewCaseDescription("");
|
||||||
|
setExportTab("export");
|
||||||
setDrawerMode("select");
|
setDrawerMode("select");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -483,9 +541,29 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
<>
|
<>
|
||||||
<SaveExportOverlay
|
<SaveExportOverlay
|
||||||
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
||||||
show={mode == "timeline"}
|
show={mode == "timeline" || mode == "timeline_multi"}
|
||||||
onSave={() => onStartExport()}
|
hidePreview={mode == "timeline_multi"}
|
||||||
onCancel={() => setMode("none")}
|
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)}
|
onPreview={() => setShowExportPreview(true)}
|
||||||
/>
|
/>
|
||||||
<SaveDebugReplayOverlay
|
<SaveDebugReplayOverlay
|
||||||
|
|||||||
403
web/src/components/overlay/MultiExportDialog.tsx
Normal file
403
web/src/components/overlay/MultiExportDialog.tsx
Normal file
@ -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 = {
|
type SaveExportOverlayProps = {
|
||||||
className: string;
|
className: string;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
hidePreview?: boolean;
|
||||||
|
saveLabel?: string;
|
||||||
|
isSaving?: boolean;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@ -14,6 +17,9 @@ type SaveExportOverlayProps = {
|
|||||||
export default function SaveExportOverlay({
|
export default function SaveExportOverlay({
|
||||||
className,
|
className,
|
||||||
show,
|
show,
|
||||||
|
hidePreview = false,
|
||||||
|
saveLabel,
|
||||||
|
isSaving = false,
|
||||||
onPreview,
|
onPreview,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
@ -32,29 +38,36 @@ export default function SaveExportOverlay({
|
|||||||
className="flex items-center gap-1 text-primary"
|
className="flex items-center gap-1 text-primary"
|
||||||
aria-label={t("button.cancel", { ns: "common" })}
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={isSaving}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
<LuX />
|
<LuX />
|
||||||
{t("button.cancel", { ns: "common" })}
|
{t("button.cancel", { ns: "common" })}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
aria-label={t("export.fromTimeline.previewExport")}
|
aria-label={saveLabel || t("export.fromTimeline.saveExport")}
|
||||||
size="sm"
|
|
||||||
onClick={onPreview}
|
|
||||||
>
|
|
||||||
<LuVideo />
|
|
||||||
{t("export.fromTimeline.previewExport")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
aria-label={t("export.fromTimeline.saveExport")}
|
|
||||||
variant="select"
|
variant="select"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={isSaving}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
>
|
>
|
||||||
<FaCompactDisc />
|
<FaCompactDisc />
|
||||||
{t("export.fromTimeline.saveExport")}
|
{isSaving
|
||||||
|
? t("export.fromTimeline.queueingExport")
|
||||||
|
: saveLabel || t("export.fromTimeline.saveExport")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -15,9 +16,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
@ -35,8 +37,8 @@ type OptionAndInputDialogProps = {
|
|||||||
nameLabel: string;
|
nameLabel: string;
|
||||||
descriptionLabel: string;
|
descriptionLabel: string;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
onSave: (value: string) => void;
|
onSave: (value: string) => Promise<void>;
|
||||||
onCreateNew: (name: string, description: string) => void;
|
onCreateNew: (name: string, description: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OptionAndInputDialog({
|
export default function OptionAndInputDialog({
|
||||||
@ -69,10 +71,12 @@ export default function OptionAndInputDialog({
|
|||||||
}
|
}
|
||||||
}, [open, initialValue, firstOption]);
|
}, [open, initialValue, firstOption]);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const isNew = selectedValue === newValueKey;
|
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) {
|
if (!selectedValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -80,13 +84,26 @@ export default function OptionAndInputDialog({
|
|||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
const trimmedDescription = descriptionValue.trim();
|
const trimmedDescription = descriptionValue.trim();
|
||||||
|
|
||||||
if (isNew) {
|
setIsLoading(true);
|
||||||
onCreateNew(trimmedName, trimmedDescription);
|
try {
|
||||||
} else {
|
if (isNew) {
|
||||||
onSave(selectedValue);
|
await onCreateNew(trimmedName, trimmedDescription);
|
||||||
|
} else {
|
||||||
|
await onSave(selectedValue);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
setOpen(false);
|
}, [
|
||||||
};
|
selectedValue,
|
||||||
|
name,
|
||||||
|
descriptionValue,
|
||||||
|
isNew,
|
||||||
|
onCreateNew,
|
||||||
|
onSave,
|
||||||
|
setOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||||
@ -127,15 +144,21 @@ export default function OptionAndInputDialog({
|
|||||||
<label className="text-sm font-medium text-secondary-foreground">
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
{nameLabel}
|
{nameLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
<Input
|
||||||
|
className="text-md"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium text-secondary-foreground">
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
{descriptionLabel}
|
{descriptionLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Textarea
|
||||||
|
className="text-md"
|
||||||
value={descriptionValue}
|
value={descriptionValue}
|
||||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||||
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -145,6 +168,7 @@ export default function OptionAndInputDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
@ -155,9 +179,13 @@ export default function OptionAndInputDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="select"
|
variant="select"
|
||||||
disabled={disableSave}
|
disabled={disableSave}
|
||||||
onClick={handleSave}
|
onClick={() => void handleSave()}
|
||||||
>
|
>
|
||||||
{t("button.save")}
|
{isLoading ? (
|
||||||
|
<ActivityIndicator className="size-4" />
|
||||||
|
) : (
|
||||||
|
t("button.save")
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
cancelButton:
|
cancelButton:
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
closeButton:
|
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:
|
success:
|
||||||
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
error:
|
error:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,8 @@ export type Export = {
|
|||||||
video_path: string;
|
video_path: string;
|
||||||
thumb_path: string;
|
thumb_path: string;
|
||||||
in_progress: boolean;
|
in_progress: boolean;
|
||||||
export_case?: string;
|
export_case?: string | null;
|
||||||
|
export_case_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExportCase = {
|
export type ExportCase = {
|
||||||
@ -17,6 +18,81 @@ export type ExportCase = {
|
|||||||
updated_at: number;
|
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 = {
|
export type DeleteClipType = {
|
||||||
file: string;
|
file: string;
|
||||||
exportName: string;
|
exportName: string;
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type FilterType = { [searchKey: string]: any };
|
export type FilterType = { [searchKey: string]: any };
|
||||||
|
|
||||||
export type ExportMode = "select" | "timeline" | "none";
|
export type ExportMode = "select" | "timeline" | "timeline_multi" | "none";
|
||||||
|
|
||||||
export type FilterList = {
|
export type FilterList = {
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
|
|||||||
@ -270,7 +270,10 @@ export default function MotionSearchView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exportMode !== "timeline" || exportRange) {
|
if (
|
||||||
|
(exportMode !== "timeline" && exportMode !== "timeline_multi") ||
|
||||||
|
exportRange
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -955,9 +958,25 @@ export default function MotionSearchView({
|
|||||||
|
|
||||||
<SaveExportOverlay
|
<SaveExportOverlay
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 z-30"
|
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}
|
onPreview={handleExportPreview}
|
||||||
onSave={handleExportSave}
|
onSave={() => {
|
||||||
|
if (exportMode === "timeline_multi") {
|
||||||
|
setExportMode("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleExportSave();
|
||||||
|
}}
|
||||||
onCancel={handleExportCancel}
|
onCancel={handleExportCancel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -976,7 +995,10 @@ export default function MotionSearchView({
|
|||||||
noRecordingRanges={noRecordings ?? []}
|
noRecordingRanges={noRecordings ?? []}
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
|
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
|
||||||
showExportHandles={exportMode === "timeline" && Boolean(exportRange)}
|
showExportHandles={
|
||||||
|
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
||||||
|
Boolean(exportRange)
|
||||||
|
}
|
||||||
exportStartTime={exportRange?.after}
|
exportStartTime={exportRange?.after}
|
||||||
exportEndTime={exportRange?.before}
|
exportEndTime={exportRange?.before}
|
||||||
setExportStartTime={setExportStartTime}
|
setExportStartTime={setExportStartTime}
|
||||||
@ -1408,7 +1430,11 @@ export default function MotionSearchView({
|
|||||||
onControllerReady={(controller) => {
|
onControllerReady={(controller) => {
|
||||||
mainControllerRef.current = controller;
|
mainControllerRef.current = controller;
|
||||||
}}
|
}}
|
||||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
isScrubbing={
|
||||||
|
scrubbing ||
|
||||||
|
exportMode == "timeline" ||
|
||||||
|
exportMode == "timeline_multi"
|
||||||
|
}
|
||||||
supportsFullscreen={supportsFullScreen}
|
supportsFullscreen={supportsFullScreen}
|
||||||
setFullResolution={setFullResolution}
|
setFullResolution={setFullResolution}
|
||||||
toggleFullscreen={toggleFullscreen}
|
toggleFullscreen={toggleFullscreen}
|
||||||
|
|||||||
@ -833,6 +833,7 @@ export function RecordingView({
|
|||||||
isScrubbing={
|
isScrubbing={
|
||||||
scrubbing ||
|
scrubbing ||
|
||||||
exportMode == "timeline" ||
|
exportMode == "timeline" ||
|
||||||
|
exportMode == "timeline_multi" ||
|
||||||
debugReplayMode == "timeline"
|
debugReplayMode == "timeline"
|
||||||
}
|
}
|
||||||
supportsFullscreen={supportsFullScreen}
|
supportsFullscreen={supportsFullScreen}
|
||||||
@ -911,7 +912,7 @@ export function RecordingView({
|
|||||||
activeReviewItem={activeReviewItem}
|
activeReviewItem={activeReviewItem}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
exportRange={
|
exportRange={
|
||||||
exportMode == "timeline"
|
exportMode == "timeline" || exportMode == "timeline_multi"
|
||||||
? exportRange
|
? exportRange
|
||||||
: debugReplayMode == "timeline"
|
: debugReplayMode == "timeline"
|
||||||
? debugReplayRange
|
? debugReplayRange
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user