Compare commits

...
4 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub aff9799451 Don't require a restart to enable GenAI descriptions (#23964)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* create GenAI post processors when a camera enables GenAI at runtime

* fix types
2026-08-12 08:55:34 -05:00
Josh HawkinsandGitHub c75611b4df Multi-export UI fixes (#23959)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* multi export fixes

* i18n

* new tests
2026-08-11 10:11:47 -06:00
Josh HawkinsandGitHub 0735a8ac75 Docs updates (#23947)
* misc docs updates

* add warning about proxies to 5000 for notifications
2026-08-10 15:54:41 -06:00
Josh HawkinsandGitHub 2599795ab0 add faq to notifications docs (#23939) 2026-08-08 11:12:13 -06:00
20 changed files with 710 additions and 137 deletions
+25
View File
@@ -50,6 +50,31 @@ Connect each stream to get a live preview, an estimated bandwidth figure, and a
Other features, including [hardware acceleration](hardware_acceleration_video.md), [two way talk](/configuration/live#two-way-talk), and audio transcoding, is configured after the camera has been added. For camera model specific quirks, see the [camera specific](camera_specific.md) docs.
## Deleting a camera
Click **Delete Camera** in <NavPath path="Settings > Global configuration > Camera management" />, choose the camera, and confirm. Deleting a camera requires the `admin` role and cannot be undone.
:::warning
Deleting a camera permanently removes its recordings, tracked objects, and configuration. If you only want to stop processing a camera, set its state to **Off** or **Disabled** in <NavPath path="Settings > Global configuration > Camera management" /> instead. See [camera state](/configuration/live#camera-state).
:::
Deleting a camera removes:
- The camera's section of your config file, along with its entries in any [role](authentication.md#user-roles) camera list. A custom role left with no cameras is removed as well.
- Every database record for the camera: tracked objects, review items, recordings, previews, timeline entries, the saved region grid, and [triggers](semantic_search.md#triggers).
- Every media file for the camera: recordings, snapshots, thumbnails, and preview clips.
[Exports](/usage/exports) are kept by default, so saved footage survives the deletion of the camera it came from. Turn on **Also delete exports for this camera** in the confirmation step to remove those too.
The camera's processes are stopped and the change takes effect immediately, so no restart is required. If the resulting config cannot be parsed, Frigate restores the previous config and reports an error instead of leaving Frigate in a broken state.
Two things are not cleaned up for you:
- **go2rtc streams.** Frigate makes a best effort to stop a running [go2rtc](go2rtc.md) stream named after the camera, but stream entries in your config file remain and are recreated on the next restart. Remove them in <NavPath path="Settings > System > go2rtc streams" /> or in your config file.
- **Camera groups.** A deleted camera stays listed in any [camera group](#setting-up-camera-groups) that referenced it. The group skips the missing camera, so this is harmless, but you can edit the group to drop the stale entry.
## Setting Up Camera Inputs
Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa.
+68 -2
View File
@@ -6,6 +6,7 @@ title: Notifications
import ConfigTabs from "@site/src/components/ConfigTabs";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
import FaqItem from "@site/src/components/FaqItem";
# Notifications
@@ -21,7 +22,7 @@ Push notifications require internet access from the Frigate server to the browse
In order to use notifications the following requirements must be met:
- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)).
- Frigate must be accessed via a secure `https` connection while signed in as a Frigate user ([see the authorization docs](/configuration/authentication)).
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
- In order for notifications to be usable externally, Frigate must be accessible externally.
- For iOS devices, some users have also indicated that the Notifications switch needs to be enabled in iOS Settings --> Apps --> Safari --> Advanced --> Features.
@@ -85,7 +86,13 @@ cameras:
### Registration
Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
Once notifications are enabled, press the `Register This Device` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
:::warning
Each registration is attached to the Frigate user account you are signed in as, so you must register over a secure connection to the authenticated port (`8971`). Reverse proxies and tunnels should point at port `8971`.
:::
## Supported Notifications
@@ -104,3 +111,62 @@ Different platforms handle notifications differently, some settings changes may
### Android
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
## Notifications FAQ
<FaqItem id="how-do-i-debug-notifications-issues" question="How do I debug notifications issues?">
Push notifications involve Frigate, your browser, and your browser vendor's push service, so it helps to work from the server outward.
1. Enable debug logs for the push client by adding `frigate.comms.webpush: debug` to your `logger` configuration. Restart Frigate after this change.
```yaml
logger:
default: info
logs:
# highlight-next-line
frigate.comms.webpush: debug
```
These logs show exactly where a notification stopped, including:
- `Email must be provided for push notifications to be sent` means the global `email` field is empty and nothing will ever be sent.
- `Sending test notification` and `Sending push notification for <camera>, review ID <id>` mean Frigate handed the message off to the push service.
- `Skipping notification for <camera> - in global cooldown period` (or `camera-specific cooldown period`) means your [cooldown](#configuration) values suppressed it.
- `Notifications for <camera> are currently suspended` means notifications were suspended from <NavPath path="Settings > Notifications" /> or MQTT.
- `Notification endpoint expired for <user>, received 410` means that device's subscription is no longer valid and it must be re-registered.
- `Failed to send notification to <user> :: <status>` means the push service rejected the message. A `401` or `403` usually points at a VAPID or `email` problem, and a `5xx` is a problem on the push service's end.
- If you see no messages at all when an alert occurs, the notification was never queued. Confirm an actual **alert** was created (notifications are not sent for detections), and that notifications are enabled both globally and for that camera.
2. Verify the basics that most reports come down to:
- Frigate must be reached over `https` with a certificate your device trusts. Browsers silently refuse to register a service worker otherwise, and a self-signed certificate that is not installed as trusted on the device will fail.
- On iOS, notifications only work when Frigate has been installed to the Home Screen via **Share > Add to Home Screen** and opened from that icon. Safari and Chrome tabs cannot receive web push on iOS.
- Each device must be registered individually, and Frigate must be restarted after registering before anything can be sent, including test notifications.
- The Frigate server needs outbound internet access to the browser vendor's push service. See [Network Requirements](/frigate/network_requirements#push-notifications).
3. Test from the UI. Use the `Send a test notification` button in <NavPath path="Settings > Notifications" />. If the log shows `Sending test notification` but nothing arrives on the device, the problem is between the push service and your device rather than in Frigate.
4. Check the browser side on the device that is not receiving notifications:
- Confirm the site's notification permission is set to **Allow** in your browser or OS settings, and that a focus/do not disturb mode is not hiding them.
- In desktop browsers, open Developer Tools > Application > Service Workers and confirm `notifications-worker.js` is registered and activated. Unregistering it and registering the device again will rebuild a broken subscription.
- Check the browser console and your reverse proxy logs for failures loading `/notifications-worker.js` or errors on `/api/notifications/register`.
</FaqItem>
<FaqItem id="why-did-notifications-stop-arriving-after-working-for-a-while" question="Why did notifications stop arriving after working for a while?">
Push subscriptions are issued by the browser vendor and can be revoked, most often after a browser update, after clearing site data, or when a device has been offline for an extended period. When this happens the device still appears registered in Frigate, but the push service rejects the message. The debug logs will show `Notification endpoint expired` with a `404` or `410` status.
Unregister and re-register the affected device from <NavPath path="Settings > Notifications" />, then restart Frigate.
</FaqItem>
<FaqItem id="why-am-i-not-getting-notifications-for-one-specific-camera" question="Why am I not getting notifications for one specific camera?">
Work through these in order:
- Notifications are only sent for **alerts**. If the camera is producing detections instead, adjust the camera's `review > alerts > labels` so the objects you care about are classified as alerts.
- Confirm notifications are enabled for that camera in <NavPath path="Settings > Camera configuration > Notifications" />.
- Check the camera's `cooldown` value, and remember that the global cooldown applies across all cameras. A busy camera can consume the global cooldown and suppress a quieter one.
- If [authentication](/configuration/authentication) is enabled with roles, users only receive notifications for the cameras their role grants access to.
</FaqItem>
+2
View File
@@ -308,6 +308,8 @@ Publishes the current health status of each role that is enabled (`audio`, `dete
- `offline`: Stream is offline and is being restarted
- `disabled`: Camera is currently turned off (either at runtime via the `enabled/set` topic, or persistently via the configuration file). See [Camera state](/configuration/live#camera-state) for the distinction.
These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message.
### `frigate/<camera_name>/<object_name>`
Publishes the count of objects for the camera for use as a sensor in Home Assistant.
+10 -2
View File
@@ -65,9 +65,17 @@ This is because Frigate does not run in host mode so localhost points to the Fri
### How do I know if my camera is offline
A camera being offline can be detected via MQTT or /api/stats, the camera_fps for any offline camera will be 0.
Frigate publishes a per-role health status to [`frigate/<camera_name>/status/<role>`](/integrations/mqtt#frigatecamera_namestatusrole), where `<role>` is each enabled role on the camera (`detect`, `record`, and `audio`). The published value is one of:
Also, Home Assistant will mark any offline camera as being unavailable when the camera is offline.
- `online`: Frigate's process for that role is running normally
- `offline`: the process is down and Frigate is restarting it
- `disabled`: the camera is turned off, either at runtime or in the configuration file
These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message.
Because the status is per role, a camera whose substream is fine but whose recording stream has dropped will report `online` for `detect` and `offline` for `record`. The status is republished whenever it changes.
You can also detect an offline camera through `/api/stats`, where `camera_fps` will be 0.
### How can I view the Frigate log files without using the Web UI?
+3 -15
View File
@@ -103,21 +103,9 @@ class FrigateApp:
self.detection_shms: list[mp.shared_memory.SharedMemory] = []
self.log_queue: Queue = mp.Queue()
self.camera_metrics: DictProxy = self.metrics_manager.dict()
self.embeddings_metrics: DataProcessorMetrics | None = (
DataProcessorMetrics(
self.metrics_manager, list(config.classification.custom.keys())
)
if (
config.semantic_search.enabled
or any(
c.objects.genai.enabled or c.review.genai.enabled
for c in config.cameras.values()
)
or config.lpr.enabled
or config.face_recognition.enabled
or len(config.classification.custom) > 0
)
else None
self.embeddings_metrics = DataProcessorMetrics(
self.metrics_manager, list(config.classification.custom.keys())
)
self.ptz_metrics: dict[str, PTZMetrics] = {}
self.processes: dict[str, int] = {}
@@ -63,8 +63,10 @@ class ObjectDescriptionProcessor(PostProcessorApi):
"""Handle an update to a frame for an object."""
camera_config = self.config.cameras[camera]
# no need to save our own thumbnails if genai is not enabled
# or if the object has become stationary
if not camera_config.objects.genai.enabled:
return
# no need to save our own thumbnails if the object has become stationary
if not data["stationary"]:
if data["id"] not in self.tracked_events:
self.tracked_events[data["id"]] = []
+1 -1
View File
@@ -33,7 +33,7 @@ class EmbeddingProcess(FrigateProcess):
def __init__(
self,
config: FrigateConfig,
metrics: DataProcessorMetrics | None,
metrics: DataProcessorMetrics,
stop_event: MpEvent,
) -> None:
super().__init__(
+62 -20
View File
@@ -78,6 +78,16 @@ logger = logging.getLogger(__name__)
MAX_THUMBNAILS = 10
GENAI_UPDATE_TOPICS = frozenset(
{
CameraConfigUpdateEnum.add.name,
CameraConfigUpdateEnum.objects.name,
CameraConfigUpdateEnum.object_genai.name,
CameraConfigUpdateEnum.review.name,
CameraConfigUpdateEnum.review_genai.name,
}
)
class EmbeddingMaintainer(threading.Thread):
"""Handle embedding queue and post event updates."""
@@ -85,7 +95,7 @@ class EmbeddingMaintainer(threading.Thread):
def __init__(
self,
config: FrigateConfig,
metrics: DataProcessorMetrics | None,
metrics: DataProcessorMetrics,
stop_event: MpEvent,
) -> None:
super().__init__(name="embeddings_maintainer")
@@ -220,16 +230,6 @@ class EmbeddingMaintainer(threading.Thread):
# post processors
self.post_processors: list[PostProcessorApi] = []
if any(c.review.genai.enabled_in_config for c in self.config.cameras.values()):
self.post_processors.append(
ReviewDescriptionProcessor(
self.config,
self.requestor,
self.metrics,
self.genai_manager,
)
)
if self.config.lpr.enabled:
self.post_processors.append(
LicensePlatePostProcessor(
@@ -252,9 +252,9 @@ class EmbeddingMaintainer(threading.Thread):
)
)
semantic_trigger_processor: SemanticTriggerProcessor | None = None
self.semantic_trigger_processor: SemanticTriggerProcessor | None = None
if self.config.semantic_search.enabled:
semantic_trigger_processor = SemanticTriggerProcessor(
self.semantic_trigger_processor = SemanticTriggerProcessor(
db,
self.config,
self.requestor,
@@ -262,9 +262,49 @@ class EmbeddingMaintainer(threading.Thread):
metrics,
self.embeddings,
)
self.post_processors.append(semantic_trigger_processor)
self.post_processors.append(self.semantic_trigger_processor)
if any(c.objects.genai.enabled_in_config for c in self.config.cameras.values()):
self._sync_genai_processors()
self.stop_event = stop_event
# recordings data
self.recordings_available_through: dict[str, float] = {}
def _sync_genai_processors(self) -> None:
"""Create GenAI post processors for cameras that have GenAI enabled.
Called at startup and again after camera config updates so enabling
GenAI on the first camera does not require a restart. Processors are
never removed once created.
A profile can turn GenAI on without setting enabled_in_config, so both
flags are checked.
"""
cameras = self.config.cameras.values()
if any(
c.review.genai.enabled or c.review.genai.enabled_in_config for c in cameras
) and not any(
isinstance(p, ReviewDescriptionProcessor) for p in self.post_processors
):
logger.debug("Initializing review description processor")
self.post_processors.append(
ReviewDescriptionProcessor(
self.config,
self.requestor,
self.metrics,
self.genai_manager,
)
)
if any(
c.objects.genai.enabled or c.objects.genai.enabled_in_config
for c in cameras
) and not any(
isinstance(p, ObjectDescriptionProcessor) for p in self.post_processors
):
logger.debug("Initializing object description processor")
self.post_processors.append(
ObjectDescriptionProcessor(
self.config,
@@ -272,19 +312,21 @@ class EmbeddingMaintainer(threading.Thread):
self.requestor,
self.metrics,
self.genai_manager,
semantic_trigger_processor,
self.semantic_trigger_processor,
)
)
self.stop_event = stop_event
def _check_camera_config_updates(self) -> None:
"""Apply camera config updates and register newly enabled processors."""
updated_topics = self.config_updater.check_for_updates()
# recordings data
self.recordings_available_through: dict[str, float] = {}
if updated_topics.keys() & GENAI_UPDATE_TOPICS:
self._sync_genai_processors()
def run(self) -> None:
"""Maintain a SQLite-vec database for semantic search."""
while not self.stop_event.is_set():
self.config_updater.check_for_updates()
self._check_camera_config_updates()
self._check_enrichment_config_updates()
self._process_requests()
self._process_updates()
+1 -1
View File
@@ -62,7 +62,7 @@ def get_latest_version(config: FrigateConfig) -> str:
def stats_init(
config: FrigateConfig,
camera_metrics: DictProxy,
embeddings_metrics: DataProcessorMetrics | None,
embeddings_metrics: DataProcessorMetrics,
detectors: dict[str, ObjectDetectProcess],
processes: dict[str, int],
) -> StatsTrackingTypes:
+213
View File
@@ -0,0 +1,213 @@
"""Tests for GenAI enablement gating in the embeddings maintainer.
Covers creating post processors when GenAI is enabled at runtime, and the
per-camera gating those processors apply once they exist.
"""
import sys
import unittest
from unittest.mock import MagicMock, patch
# Mock TFLite before importing the maintainer
_MOCK_MODULES = [
"tflite_runtime",
"tflite_runtime.interpreter",
"ai_edge_litert",
"ai_edge_litert.interpreter",
]
for mod in _MOCK_MODULES:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
# imported from the maintainer to avoid tripping the circular import between
# the maintainer and the processor modules
from frigate.embeddings.maintainer import ( # noqa: E402
EmbeddingMaintainer,
ObjectDescriptionProcessor,
PostProcessDataEnum,
ReviewDescriptionProcessor,
)
class TestGenAIProcessorSync(unittest.TestCase):
"""Enabling GenAI on the first camera must not require a restart."""
def _make_maintainer(
self,
review: bool = False,
objects: bool = False,
review_in_config: bool | None = None,
objects_in_config: bool | None = None,
) -> EmbeddingMaintainer:
# Bypass the heavy __init__; only the attributes touched by
# _sync_genai_processors are needed for these tests.
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
maintainer.post_processors = []
maintainer.config = MagicMock()
maintainer.config.cameras = {
"front": self._make_camera(
review,
objects,
review if review_in_config is None else review_in_config,
objects if objects_in_config is None else objects_in_config,
)
}
maintainer.config_updater = MagicMock()
maintainer.embeddings = None
maintainer.requestor = MagicMock()
maintainer.metrics = MagicMock()
maintainer.genai_manager = MagicMock()
maintainer.semantic_trigger_processor = None
return maintainer
def _make_camera(
self,
review: bool,
objects: bool,
review_in_config: bool,
objects_in_config: bool,
) -> MagicMock:
camera = MagicMock()
camera.review.genai.enabled = review
camera.review.genai.enabled_in_config = review_in_config
camera.objects.genai.enabled = objects
camera.objects.genai.enabled_in_config = objects_in_config
return camera
def _processor_types(self, maintainer: EmbeddingMaintainer) -> list[type]:
return [type(p) for p in maintainer.post_processors]
def test_no_processors_when_genai_disabled(self):
"""A config with no GenAI cameras registers neither processor."""
maintainer = self._make_maintainer()
maintainer._sync_genai_processors()
self.assertEqual(maintainer.post_processors, [])
def test_review_processor_added_when_enabled_after_startup(self):
"""Enabling review GenAI on the first camera registers the processor."""
maintainer = self._make_maintainer()
maintainer._sync_genai_processors()
camera = maintainer.config.cameras["front"]
camera.review.genai.enabled = True
camera.review.genai.enabled_in_config = True
maintainer._sync_genai_processors()
self.assertEqual(
self._processor_types(maintainer), [ReviewDescriptionProcessor]
)
def test_object_processor_added_when_enabled_after_startup(self):
"""Enabling object GenAI on the first camera registers the processor."""
maintainer = self._make_maintainer()
maintainer._sync_genai_processors()
camera = maintainer.config.cameras["front"]
camera.objects.genai.enabled = True
camera.objects.genai.enabled_in_config = True
maintainer._sync_genai_processors()
self.assertEqual(
self._processor_types(maintainer), [ObjectDescriptionProcessor]
)
def test_processor_added_when_only_enabled_by_profile(self):
"""A profile enables GenAI without setting enabled_in_config."""
maintainer = self._make_maintainer(
review=True, objects=True, review_in_config=False, objects_in_config=False
)
maintainer._sync_genai_processors()
self.assertEqual(
self._processor_types(maintainer),
[ReviewDescriptionProcessor, ObjectDescriptionProcessor],
)
def test_processors_are_not_duplicated(self):
"""Repeated config updates must not register a second processor."""
maintainer = self._make_maintainer(review=True, objects=True)
maintainer._sync_genai_processors()
maintainer._sync_genai_processors()
self.assertEqual(
self._processor_types(maintainer),
[ReviewDescriptionProcessor, ObjectDescriptionProcessor],
)
def test_genai_topic_triggers_sync(self):
"""A camera config update on a GenAI topic registers the processor."""
maintainer = self._make_maintainer(review=True)
maintainer.config_updater.check_for_updates.return_value = {"review": ["front"]}
maintainer._check_camera_config_updates()
self.assertEqual(
self._processor_types(maintainer), [ReviewDescriptionProcessor]
)
def test_unrelated_topic_does_not_sync(self):
"""An unrelated camera config update must not register processors."""
maintainer = self._make_maintainer(review=True)
maintainer.config_updater.check_for_updates.return_value = {"motion": ["front"]}
maintainer._check_camera_config_updates()
self.assertEqual(maintainer.post_processors, [])
class TestObjectDescriptionCameraGating(unittest.TestCase):
"""One camera enabling object descriptions must not enlist the others."""
def _make_processor(self, enabled: bool) -> ObjectDescriptionProcessor:
config = MagicMock()
camera = MagicMock()
camera.objects.genai.enabled = enabled
camera.objects.genai.send_triggers.after_significant_updates = None
config.cameras = {"front": camera}
genai_manager = MagicMock()
genai_manager.description_client = MagicMock()
return ObjectDescriptionProcessor(
config, None, MagicMock(), MagicMock(), genai_manager, None
)
def _update(self, processor: ObjectDescriptionProcessor) -> None:
processor.process_data(
{
"camera": "front",
"data": {
"id": "1234.5-abcdef",
"box": (0, 0, 10, 10),
"stationary": False,
},
"state": "update",
"yuv_frame": MagicMock(),
},
PostProcessDataEnum.tracked_object,
)
@patch("frigate.data_processing.post.object_descriptions.create_thumbnail")
def test_disabled_camera_collects_no_thumbnails(self, mock_create_thumbnail):
"""A camera with object descriptions off does no thumbnail work."""
processor = self._make_processor(enabled=False)
self._update(processor)
mock_create_thumbnail.assert_not_called()
self.assertEqual(processor.tracked_events, {})
@patch("frigate.data_processing.post.object_descriptions.create_thumbnail")
def test_enabled_camera_collects_thumbnails(self, mock_create_thumbnail):
"""A camera with object descriptions on still collects thumbnails."""
mock_create_thumbnail.return_value = b"jpg"
processor = self._make_processor(enabled=True)
self._update(processor)
mock_create_thumbnail.assert_called_once()
self.assertEqual(len(processor.tracked_events["1234.5-abcdef"]), 1)
+1 -1
View File
@@ -8,7 +8,7 @@ from frigate.object_detection.base import ObjectDetectProcess
class StatsTrackingTypes(TypedDict):
camera_metrics: dict[str, CameraMetrics]
embeddings_metrics: DataProcessorMetrics | None
embeddings_metrics: DataProcessorMetrics
detectors: dict[str, ObjectDetectProcess]
started: int
latest_frigate_version: str
+133 -11
View File
@@ -1,4 +1,4 @@
import { test, expect } from "../fixtures/frigate-test";
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
@@ -575,7 +575,7 @@ test.describe("Multi-Review Export @high", () => {
await expect(dialog.getByText(/None/)).toBeVisible();
});
test("starting an export posts the expected payload and navigates to the case", async ({
test("starting an export posts the expected payload and stays on the review page", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
@@ -673,9 +673,15 @@ test.describe("Multi-Review Export @high", () => {
"mex-review-002",
]);
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
timeout: 5_000,
});
// Creating a case must not pull the user off the review they were
// working through — the case is offered as a link on the toast instead.
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
await expect(viewCase).toBeVisible({ timeout: 5_000 });
await expect(viewCase).toHaveAttribute(
"href",
/export\?caseId=new-case-xyz$/,
);
await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/);
});
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
@@ -834,12 +840,128 @@ test.describe("Multi-Review Export @high", () => {
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,
});
// Attaching to a case leaves the user on the review page; the case is
// reachable from the toast action.
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
await expect(viewCase).toBeVisible({ timeout: 5_000 });
await expect(viewCase).toHaveAttribute(
"href",
/export\?caseId=existing-case-abc$/,
);
await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/);
});
});
test.describe("Multi-Camera Export from History @high", () => {
// The recording view seeds the multi-camera range around the playback
// position, so the deep link has to land close to the live edge for the
// seeded end to run past the end of the timeline.
const playbackTime = Math.floor(Date.now() / 1000) - 300;
async function openRecordingView(frigateApp: FrigateApp) {
// The recording view pulls these while the timeline renders; the preview
// server 500s on them, which the error collector would flag.
await frigateApp.page.route("**/api/*/recordings**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
}
// Desktop opens the export form in a dialog from the Actions menu; mobile
// opens the same form inside the settings drawer.
async function openMultiCameraTab(frigateApp: FrigateApp) {
await openRecordingView(frigateApp);
if (frigateApp.isMobile) {
await frigateApp.page
.getByRole("button", { name: /filters/i })
.first()
.click({ timeout: 15_000 });
await frigateApp.page.getByRole("button", { name: /^export$/i }).click();
} else {
await frigateApp.page
.getByRole("button", { name: /actions/i })
.click({ timeout: 15_000 });
await frigateApp.page.getByRole("menuitem", { name: /export/i }).click();
}
const form = frigateApp.page.getByRole("dialog");
await expect(form).toBeVisible({ timeout: 5_000 });
await form.getByRole("tab", { name: /multi-camera/i }).click();
return form;
}
test("timeline selection renders both export handles on the timeline", async ({
frigateApp,
}) => {
await frigateApp.installDefaults();
const form = await openMultiCameraTab(frigateApp);
await form
.getByRole("button", { name: "Select from Timeline" })
.click({ timeout: 5_000 });
await expect(form).toBeHidden({ timeout: 5_000 });
// A range seeded past the end of the timeline has no segment to anchor
// to, which leaves the handle unpositioned at the top of the timeline
// with an empty label until it is dragged.
for (const handle of [".export-start", ".export-end"]) {
const locator = frigateApp.page.locator(handle);
await expect(locator).toHaveText(/\d{1,2}:\d{2}/, { timeout: 5_000 });
await expect(locator).not.toHaveAttribute("style", /top:\s*0px/);
}
});
test("the time range picker opens without a configured timezone", async ({
frigateApp,
}) => {
await frigateApp.installDefaults();
const form = await openMultiCameraTab(frigateApp);
// ui.timezone is null until the user sets one, which used to take the
// whole page down when the calendar worked out its disabled days
await form
.getByRole("button", { name: /^start time$/i })
.click({ timeout: 5_000 });
await expect(
frigateApp.page.getByRole("button", { name: /previous month/i }),
).toBeVisible({ timeout: 5_000 });
});
test("canceling timeline selection reopens the form with the case intact", async ({
frigateApp,
}) => {
await frigateApp.installDefaults();
const form = await openMultiCameraTab(frigateApp);
await form
.getByPlaceholder(/new case name/i)
.fill("Incident 7", { timeout: 5_000 });
await form
.getByPlaceholder(/case description/i)
.fill("Front gate follow-up");
await form.getByRole("button", { name: "Select from Timeline" }).click();
await expect(form).toBeHidden({ timeout: 5_000 });
await frigateApp.page.getByRole("button", { name: /cancel/i }).click();
await expect(form).toBeVisible({ timeout: 5_000 });
await expect(
form.getByRole("tab", { name: /multi-camera/i }),
).toHaveAttribute("aria-selected", "true");
await expect(form.getByPlaceholder(/new case name/i)).toHaveValue(
"Incident 7",
);
await expect(form.getByPlaceholder(/case description/i)).toHaveValue(
"Front gate follow-up",
);
});
});
+4 -6
View File
@@ -100,10 +100,8 @@
"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.",
"started_one": "Started 1 export.",
"started_other": "Started {{count}} exports.",
"partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}",
"failed": "Failed to start {{total}} exports. Failed: {{failedItems}}"
}
@@ -116,8 +114,8 @@
"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.",
"batchQueuedSuccess_one": "Queued 1 export.",
"batchQueuedSuccess_other": "Queued {{count}} exports.",
"batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
"batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}",
"error": {
@@ -258,6 +258,7 @@ export default function ReviewFilterGroup({
// not applicable as exports are not used
camera=""
latestTime={0}
earliestTime={0}
currentTime={0}
mode="none"
setMode={() => {}}
+115 -52
View File
@@ -39,6 +39,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import {
Command,
CommandGroup,
@@ -62,7 +63,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Textarea } from "../ui/textarea";
import { useNavigate } from "react-router-dom";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { isReplayCamera } from "@/utils/cameraUtil";
import { isValidIconName } from "@/utils/iconUtil";
@@ -79,9 +79,14 @@ const EXPORT_OPTIONS = [
type ExportOption = (typeof EXPORT_OPTIONS)[number];
export type ExportTab = "export" | "multi";
// length of a range seeded around the current playback time
const MULTI_CAMERA_RANGE_SECONDS = 3600;
const TIMELINE_SELECTION_SECONDS = 60;
type ExportDialogProps = {
camera: string;
latestTime: number;
earliestTime: number;
currentTime: number;
range?: TimeRange;
mode: ExportMode;
@@ -94,6 +99,7 @@ type ExportDialogProps = {
export default function ExportDialog({
camera,
latestTime,
earliestTime,
currentTime,
range,
mode,
@@ -107,9 +113,13 @@ export default function ExportDialog({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [batchCaseSelection, setBatchCaseSelection] = useState("new");
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [activeTab, setActiveTab] = useState<ExportTab>("export");
const [isStartingExport, setIsStartingExport] = useState(false);
const previousModeRef = useRef<ExportMode>(mode);
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
useEffect(() => {
const previousMode = previousModeRef.current;
@@ -188,6 +198,9 @@ export default function ExportDialog({
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setRange(undefined);
setMode("none");
return true;
@@ -223,14 +236,32 @@ export default function ExportDialog({
]);
const handleCancel = useCallback(() => {
if (mode == "timeline_multi") {
setRange(preTimelineRangeRef.current);
setMode("select");
return;
}
setName("");
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setMode("none");
setRange(undefined);
setActiveTab("export");
}, [setMode, setRange]);
}, [mode, setMode, setRange]);
const onSelectFromTimeline = useCallback(
(initialRange: TimeRange) => {
preTimelineRangeRef.current = range;
setRange(initialRange);
setMode("timeline_multi");
},
[range, setMode, setRange],
);
const Overlay = isDesktop ? Dialog : Drawer;
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
@@ -304,12 +335,16 @@ export default function ExportDialog({
>
<ExportContent
latestTime={latestTime}
earliestTime={earliestTime}
currentTime={currentTime}
range={range}
name={name}
selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
batchCaseSelection={batchCaseSelection}
newCaseName={newCaseName}
newCaseDescription={newCaseDescription}
activeTab={activeTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
@@ -318,8 +353,12 @@ export default function ExportDialog({
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setBatchCaseSelection={setBatchCaseSelection}
setNewCaseName={setNewCaseName}
setNewCaseDescription={setNewCaseDescription}
setRange={setRange}
setMode={setMode}
onSelectFromTimeline={onSelectFromTimeline}
onCancel={handleCancel}
/>
</Content>
@@ -330,12 +369,16 @@ export default function ExportDialog({
type ExportContentProps = {
latestTime: number;
earliestTime: number;
currentTime: number;
range?: TimeRange;
name: string;
selectedCaseId?: string;
singleNewCaseName: string;
singleNewCaseDescription: string;
batchCaseSelection: string;
newCaseName: string;
newCaseDescription: string;
activeTab: ExportTab;
isStartingExport: boolean;
onStartExport: () => Promise<boolean>;
@@ -344,19 +387,27 @@ type ExportContentProps = {
setSelectedCaseId: (caseId: string | undefined) => void;
setSingleNewCaseName: (name: string) => void;
setSingleNewCaseDescription: (description: string) => void;
setBatchCaseSelection: (caseId: string) => void;
setNewCaseName: (name: string) => void;
setNewCaseDescription: (description: string) => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
onSelectFromTimeline: (range: TimeRange) => void;
onCancel: () => void;
};
export function ExportContent({
latestTime,
earliestTime,
currentTime,
range,
name,
selectedCaseId,
singleNewCaseName,
singleNewCaseDescription,
batchCaseSelection,
newCaseName,
newCaseDescription,
activeTab,
isStartingExport,
onStartExport,
@@ -365,12 +416,15 @@ export function ExportContent({
setSelectedCaseId,
setSingleNewCaseName,
setSingleNewCaseDescription,
setBatchCaseSelection,
setNewCaseName,
setNewCaseDescription,
setRange,
setMode,
onSelectFromTimeline,
onCancel,
}: ExportContentProps) {
const { t } = useTranslation(["components/dialog"]);
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
@@ -379,13 +433,8 @@ export function ExportContent({
range,
);
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
selectedCaseId || "none",
);
const [hasManualCameraSelection, setHasManualCameraSelection] =
useState(false);
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
const [cameraSearch, setCameraSearch] = useState("");
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
@@ -416,38 +465,47 @@ export function ExportContent({
return () => window.clearTimeout(timeoutId);
}, [activeTab, range]);
useEffect(() => {
if (activeTab !== "multi") {
return;
}
if (selectedCaseId) {
setBatchCaseSelection(selectedCaseId);
return;
}
if ((cases?.length ?? 0) === 0) {
setBatchCaseSelection("new");
return;
}
setBatchCaseSelection("new");
}, [activeTab, cases?.length, selectedCaseId]);
useEffect(() => {
setHasManualCameraSelection(false);
}, [multiRangeKey]);
const buildRangeAroundCurrentTime = useCallback(
(durationSeconds: number): TimeRange => ({
after: Math.max(earliestTime, currentTime - durationSeconds / 2),
before: Math.min(latestTime, currentTime + durationSeconds / 2),
}),
[currentTime, earliestTime, latestTime],
);
const clampRangeToTimeline = useCallback(
(candidate?: TimeRange): TimeRange => {
const fallback = buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS);
if (!candidate) {
return fallback;
}
const after = Math.min(
latestTime,
Math.max(earliestTime, candidate.after),
);
const before = Math.min(
latestTime,
Math.max(earliestTime, candidate.before),
);
return before > after ? { after, before } : fallback;
},
[buildRangeAroundCurrentTime, earliestTime, latestTime],
);
useEffect(() => {
if (activeTab !== "multi" || range) {
return;
}
setRange({
before: currentTime + 1800,
after: currentTime - 1800,
});
}, [activeTab, currentTime, range, setRange]);
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
}, [activeTab, buildRangeAroundCurrentTime, range, setRange]);
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
activeTab === "multi" && debouncedRange
@@ -715,6 +773,16 @@ export function ExportContent({
return result.error ? `${cameraName}: ${result.error}` : cameraName;
})
.join(", ");
const exportCaseId = response.data.export_case_id;
const viewCaseAction = exportCaseId ? (
<a
href={`${baseUrl}export?caseId=${exportCaseId}`}
target="_blank"
rel="noopener noreferrer"
>
<Button>{t("export.toast.view")}</Button>
</a>
) : undefined;
if (failedResults.length > 0 && successfulResults.length > 0) {
toast.success(
@@ -728,6 +796,7 @@ export function ExportContent({
{
position: "top-center",
description: failedSummary,
action: viewCaseAction,
},
);
} else if (failedResults.length > 0) {
@@ -748,7 +817,7 @@ export function ExportContent({
t("export.toast.batchQueuedSuccess", {
count: successfulResults.length,
}),
{ position: "top-center" },
{ position: "top-center", action: viewCaseAction },
);
}
@@ -761,9 +830,6 @@ export function ExportContent({
setRange(undefined);
setMode("none");
setActiveTab("export");
if (response.data.export_case_id) {
navigate(`/export?caseId=${response.data.export_case_id}`);
}
}
} catch (error) {
const apiError = error as {
@@ -794,12 +860,14 @@ export function ExportContent({
range,
selectedCameraIds,
setActiveTab,
setBatchCaseSelection,
setMode,
setName,
setNewCaseDescription,
setNewCaseName,
setRange,
setSelectedCaseId,
t,
navigate,
]);
return (
@@ -820,10 +888,8 @@ export function ExportContent({
onValueChange={(value) => {
const tab = value as ExportTab;
if (tab === "multi") {
setRange({
before: currentTime + 1800,
after: currentTime - 1800,
});
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
setBatchCaseSelection(selectedCaseId ?? "new");
} else {
onSelectTime(selectedOption);
}
@@ -975,23 +1041,18 @@ export function ExportContent({
className="size-9 shrink-0 p-0"
aria-label={t("export.multiCamera.selectFromTimeline")}
onClick={() => {
if (!range) {
setRange({
before: currentTime + 30,
after: currentTime - 30,
});
}
setActiveTab("multi");
setMode("timeline_multi");
onSelectFromTimeline(clampRangeToTimeline(range));
}}
>
<LuAudioLines className="size-4 -rotate-90" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("export.multiCamera.selectFromTimeline")}
</TooltipContent>
<TooltipPortal>
<TooltipContent>
{t("export.multiCamera.selectFromTimeline")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
@@ -1256,7 +1317,9 @@ export function ExportContent({
disabled={isStartingExport}
onClick={async () => {
if (selectedOption == "timeline") {
setRange({ before: currentTime + 30, after: currentTime - 30 });
setRange(
buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS),
);
setMode("timeline");
} else {
const didQueue = await onStartExport();
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { baseUrl } from "@/api/baseUrl";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
@@ -65,6 +65,7 @@ type MobileReviewSettingsDrawerProps = {
filter?: ReviewFilter;
currentSeverity?: ReviewSeverity;
latestTime: number;
earliestTime: number;
currentTime: number;
range?: TimeRange;
mode: ExportMode;
@@ -90,6 +91,7 @@ export default function MobileReviewSettingsDrawer({
filter,
currentSeverity,
latestTime,
earliestTime,
currentTime,
range,
mode,
@@ -142,7 +144,22 @@ export default function MobileReviewSettingsDrawer({
);
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [batchCaseSelection, setBatchCaseSelection] = useState("new");
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isStartingExport, setIsStartingExport] = useState(false);
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
const onSelectFromTimeline = useCallback(
(initialRange: TimeRange) => {
preTimelineRangeRef.current = range;
setRange(initialRange);
setMode("timeline_multi");
setDrawerMode("none");
},
[range, setMode, setRange],
);
const onStartExport = useCallback(async () => {
if (isStartingExport) {
return false;
@@ -214,6 +231,9 @@ export default function MobileReviewSettingsDrawer({
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setRange(undefined);
setMode("none");
return true;
@@ -433,12 +453,16 @@ export default function MobileReviewSettingsDrawer({
content = (
<ExportContent
latestTime={latestTime}
earliestTime={earliestTime}
currentTime={currentTime}
range={range}
name={name}
selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
batchCaseSelection={batchCaseSelection}
newCaseName={newCaseName}
newCaseDescription={newCaseDescription}
activeTab={exportTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
@@ -447,6 +471,9 @@ export default function MobileReviewSettingsDrawer({
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setBatchCaseSelection={setBatchCaseSelection}
setNewCaseName={setNewCaseName}
setNewCaseDescription={setNewCaseDescription}
setRange={setRange}
setMode={(mode) => {
setMode(mode);
@@ -455,12 +482,16 @@ export default function MobileReviewSettingsDrawer({
setDrawerMode("none");
}
}}
onSelectFromTimeline={onSelectFromTimeline}
onCancel={() => {
setMode("none");
setRange(undefined);
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setExportTab("export");
setDrawerMode("select");
}}
@@ -639,6 +670,14 @@ export default function MobileReviewSettingsDrawer({
void onStartExport();
}}
onCancel={() => {
if (mode == "timeline_multi") {
setRange(preTimelineRangeRef.current);
setExportTab("multi");
setMode("select");
setDrawerMode("export");
return;
}
setExportTab("export");
setRange(undefined);
setMode("none");
@@ -3,7 +3,6 @@ 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 {
@@ -43,6 +42,7 @@ import {
ExportCase,
} from "@/types/export";
import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl";
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { useDateLocale } from "@/hooks/use-date-locale";
@@ -65,7 +65,6 @@ export default function MultiExportDialog({
}: MultiExportDialogProps) {
const { t } = useTranslation(["components/dialog", "common"]);
const locale = useDateLocale();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -203,19 +202,24 @@ export default function MultiExportDialog({
const results = response.data.results ?? [];
const successful = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
const exportCaseId = response.data.export_case_id;
const viewCaseAction = exportCaseId ? (
<a
href={`${baseUrl}export?caseId=${exportCaseId}`}
target="_blank"
rel="noopener noreferrer"
>
<Button>{t("export.toast.view", { ns: "components/dialog" })}</Button>
</a>
) : undefined;
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" },
t("export.multi.toast.started", {
ns: "components/dialog",
count: successful.length,
}),
{ position: "top-center", action: viewCaseAction },
);
} else if (successful.length > 0 && failed.length > 0) {
// Resolve each failure to its review via item_index so same-camera
@@ -229,7 +233,7 @@ export default function MultiExportDialog({
total: results.length,
failedItems: failedLabels,
}),
{ position: "top-center" },
{ position: "top-center", action: viewCaseAction },
);
} else {
const failedLabels = failed.map(formatFailureLabel).join(", ");
@@ -247,9 +251,6 @@ export default function MultiExportDialog({
onStarted();
setOpen(false);
resetState();
if (response.data.export_case_id) {
navigate(`/export?caseId=${response.data.export_case_id}`);
}
}
} catch (error) {
const apiError = error as {
@@ -275,7 +276,6 @@ export default function MultiExportDialog({
formatFailureLabel,
isAdmin,
isNewCase,
navigate,
newCaseDescription,
newCaseName,
onStarted,
+6 -6
View File
@@ -318,12 +318,12 @@ export const formatSecondsToDuration = (
* @param timezone string representation of the timezone the user is requesting
* @returns number of minutes offset from UTC
*/
export const getUTCOffset = (
date: Date,
timezone: string = getResolvedTimeZone(),
): number => {
export const getUTCOffset = (date: Date, timezone?: string | null): number => {
// ui.timezone comes back as null until the user sets one
const resolvedTimezone = timezone || getResolvedTimeZone();
// If timezone is in UTC±HH:MM format, parse it to get offset
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
const utcOffsetMatch = resolvedTimezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
if (utcOffsetMatch) {
const hours = parseInt(utcOffsetMatch[2], 10);
const minutes = parseInt(utcOffsetMatch[3], 10);
@@ -334,7 +334,7 @@ export const getUTCOffset = (
const utcDate = new Date(date.getTime());
// locale of en-CA is required for proper locale format
let iso = utcDate
.toLocaleString("en-CA", { timeZone: timezone, hour12: false })
.toLocaleString("en-CA", { timeZone: resolvedTimezone, hour12: false })
.replace(", ", "T");
iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`;
let target = new Date(`${iso}Z`);
@@ -1356,6 +1356,7 @@ export default function MotionSearchView({
camera={selectedCamera}
currentTime={currentTime}
latestTime={timeRange.before}
earliestTime={timeRange.after}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
@@ -1476,6 +1477,7 @@ export default function MotionSearchView({
camera={selectedCamera}
currentTime={currentTime}
latestTime={timeRange.before}
earliestTime={timeRange.after}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
@@ -677,6 +677,7 @@ export function RecordingView({
camera={mainCamera}
currentTime={currentTime}
latestTime={timeRange.before}
earliestTime={timeRange.after}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
@@ -810,6 +811,7 @@ export function RecordingView({
filter={filter}
currentTime={currentTime}
latestTime={timeRange.before}
earliestTime={timeRange.after}
recordingsSummary={recordingsSummary}
mode={exportMode}
range={exportRange}