mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-21 03:09:02 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66d1e31005 | ||
|
|
39a3667f39 | ||
|
|
2ed70bd693 | ||
|
|
90248ef243 | ||
|
|
7e0e0635b8 | ||
|
|
ec44398b1c | ||
|
|
d556ff8df2 | ||
|
|
3a09d01bbe | ||
|
|
0bdf5002a0 | ||
|
|
a4a592b4e6 |
@@ -1,6 +1,6 @@
|
||||
# Nvidia ONNX Runtime GPU Support
|
||||
--extra-index-url 'https://pypi.nvidia.com'
|
||||
cython==3.0.*; platform_machine == 'x86_64'
|
||||
cython==3.2.*; platform_machine == 'x86_64'
|
||||
nvidia-cuda-cupti-cu12==12.8.90; platform_machine == 'x86_64'
|
||||
nvidia-cublas-cu12==12.8.4.1; platform_machine == 'x86_64'
|
||||
nvidia-cudnn-cu12==9.8.0.87; platform_machine == 'x86_64'
|
||||
|
||||
@@ -67,7 +67,7 @@ Additional cameras are simply added under the camera configuration section.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Management" /> and use the add camera button to configure each additional camera.
|
||||
Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and use the add camera button to configure each additional camera.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -113,7 +113,7 @@ Here are some common starter configuration examples. These can be configured thr
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
7. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
|
||||
|
||||
</TabItem>
|
||||
@@ -192,7 +192,7 @@ cameras:
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
7. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
|
||||
|
||||
</TabItem>
|
||||
@@ -270,7 +270,7 @@ cameras:
|
||||
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
7. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
7. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
8. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -257,19 +257,38 @@ cameras:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Disabling cameras
|
||||
### Camera state
|
||||
|
||||
Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI.
|
||||
Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**:
|
||||
|
||||
:::note
|
||||
- **On** — streams are processed normally. Object detection, recording, and Live view are active.
|
||||
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. This state does **not** persist across Frigate restarts; the camera returns to On after a restart.
|
||||
- **Disabled** — the change is saved to your configuration file (`enabled: False`). The camera stops immediately, Frigate stops ffmpeg processes, and all live and historical UI elements for the camera are no longer visible but remains retained on disk. The camera is still listed in **Settings → Global configuration → Camera management** so it can be re-enabled. **A restart of Frigate is required to bring a disabled camera back to On.**
|
||||
|
||||
Disabling a camera via the Frigate UI or MQTT is temporary and does not persist through restarts of Frigate.
|
||||
#### Turning a camera on or off
|
||||
|
||||
:::
|
||||
Turning a camera off is temporary and does not require a restart. The available controls are:
|
||||
|
||||
For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
|
||||
- The power button in the single-camera Live view header
|
||||
- The right-click context menu on a camera tile on the Live dashboard
|
||||
- The Camera management settings pane (status set to **Off**)
|
||||
- The mobile settings drawer on the single-camera Live view (admin users only)
|
||||
- The [MQTT topic](/integrations/mqtt#frigatecamera_nameenabledset) `frigate/<camera_name>/enabled/set` with payload `ON` or `OFF`
|
||||
- The Home Assistant integration via the [`camera.turn_on` / `camera.turn_off` actions](/integrations/home-assistant#camera-api)
|
||||
|
||||
Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily.
|
||||
#### Disabling a camera
|
||||
|
||||
Disabling a camera saves the change to your configuration file. Navigate to **Settings → Global configuration → Camera management** and set the camera's status to **Disabled**. Runtime processing stops immediately; the change persists across restarts.
|
||||
|
||||
Re-enabling a disabled camera requires a restart of Frigate so that the ffmpeg processes and other camera-scoped resources can be initialized. The UI will prompt you to restart when you switch a disabled camera back to On.
|
||||
|
||||
#### Restream behavior
|
||||
|
||||
For both Off and Disabled cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
|
||||
|
||||
#### Choosing Off versus Disabled
|
||||
|
||||
If you want a camera's historical data (review items, tracked objects, footage) to stay accessible in the UI while you stop processing, set the camera to **Off**. If you want the camera fully removed from the Live dashboard, review filters, and other UI surfaces, set it to **Disabled**. The Disabled state still keeps the camera in Camera management so it can be re-enabled later; if you want to remove all traces of a camera including its configuration, delete it via Camera management instead.
|
||||
|
||||
### Live player error messages
|
||||
|
||||
|
||||
@@ -197,3 +197,7 @@ This option is handy when you want to prevent large transient changes from trigg
|
||||
When the skip threshold is exceeded, **no motion is reported** for that frame, meaning **nothing is recorded** for that frame. That means you can miss something important, like a PTZ camera auto-tracking an object or activity while the camera is moving. If you prefer to guarantee that every frame is saved, leave this unset and accept occasional recordings containing scene noise — they typically only take up a few megabytes and are quick to scan in the timeline UI.
|
||||
|
||||
:::
|
||||
|
||||
## Reviewing Detected Motion
|
||||
|
||||
To review what the detector picked up — or to search past recordings for motion in a specific region — see [Reviewing Motion](review.md#reviewing-motion) on the Review page.
|
||||
|
||||
@@ -33,10 +33,10 @@ The easiest way to define profiles is to use the Frigate UI. Profiles can also b
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. **Create a profile** — Navigate to <NavPath path="Settings > Camera configuration > Profiles" />. Click the **Add Profile** button, enter a name (and optionally a profile ID).
|
||||
1. **Create a profile** — Navigate to <NavPath path="Settings > Global configuration > Profiles" />. Click the **Add Profile** button, enter a name (and optionally a profile ID).
|
||||
2. **Configure overrides** — Navigate to a camera configuration section (e.g. Motion detection, Record, Notifications). In the top right, two buttons will appear - choose a camera and a profile from the profile selector to edit overrides for that camera and section. Only the fields you change will be stored as overrides — fields that require a restart are hidden since profiles are applied at runtime. You can click the **Remove Profile Override** button to clear overrides.
|
||||
3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to <NavPath path="Settings > Camera configuration > Profiles" />, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers.
|
||||
4. **Delete a profile** — Navigate to <NavPath path="Settings > Camera configuration > Profiles" />, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
|
||||
3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to <NavPath path="Settings > Global configuration > Profiles" />, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers.
|
||||
4. **Delete a profile** — Navigate to <NavPath path="Settings > Global configuration > Profiles" />, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -126,7 +126,9 @@ Only the fields you explicitly set in a profile override are applied. All other
|
||||
|
||||
## Activating Profiles
|
||||
|
||||
Profiles can be activated and deactivated from the Frigate UI. Open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
|
||||
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), or the Home Assistant integration.
|
||||
|
||||
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
|
||||
|
||||
## Example: Home / Away Setup
|
||||
|
||||
@@ -135,10 +137,10 @@ A common use case is having different detection and notification settings based
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Profiles" /> and create two profiles: **Home** and **Away**.
|
||||
1. Navigate to <NavPath path="Settings > Global configuration > Profiles" /> and create two profiles: **Home** and **Away**.
|
||||
2. From to the Camera configuration section in Settings, choose the **front_door** camera, and select the **Away** profile from the profile dropdown. Then, enable notifications from the Notifications pane, and set alert labels to `person` and `car` from the Review pane. Then, from the profile dropdown choose **Home** profile, then navigate to Notifications to disable notifications.
|
||||
3. For the **indoor_cam** camera, perform similar steps - configure the **Away** profile to enable the camera, detection, and recording. Configure the **Home** profile to disable the camera entirely for privacy.
|
||||
4. Activate the desired profile from <NavPath path="Settings > Camera configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
|
||||
4. Activate the desired profile from <NavPath path="Settings > Global configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -207,3 +209,27 @@ In this example:
|
||||
- **Away profile**: The front door camera enables notifications and tracks specific alert labels. The indoor camera is fully enabled with detection and recording.
|
||||
- **Home profile**: The front door camera disables notifications. The indoor camera is completely disabled for privacy.
|
||||
- **No profile active**: All cameras use their base configuration values.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Can I define a zone or mask in a profile but not have it in the base config?
|
||||
|
||||
No. Profiles are pure overrides. Every zone and mask defined under a profile must reference an entry that already exists on the base camera config. Configurations that introduce profile-only zones or masks are rejected at startup.
|
||||
|
||||
If you want a zone or mask to be active only under a specific profile, define it on the base config with `enabled: false`, then enable it in that profile's overrides.
|
||||
|
||||
### How do I revert a profile zone or mask override back to the base configuration?
|
||||
|
||||
Delete the override. In the Frigate UI, edit the profile and use the "Revert override" action (the trash can icon) on the zone or mask. The base entry is left untouched, and once the override is removed the profile inherits the base values for that zone or mask.
|
||||
|
||||
### Can multiple profiles be active at the same time?
|
||||
|
||||
No. Only one profile can be active at a time. Activating a new profile automatically deactivates the current one.
|
||||
|
||||
### What happens to my profile overrides if I delete a zone or mask from the base?
|
||||
|
||||
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
|
||||
|
||||
### Why are some settings missing when I configure a profile override?
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
@@ -840,8 +840,8 @@ cameras:
|
||||
# Required: name of the camera
|
||||
back:
|
||||
# Optional: Enable/Disable the camera (default: shown below).
|
||||
# If disabled: config is used but no live stream and no capture etc.
|
||||
# Events/Recordings are still viewable.
|
||||
# When False, ffmpeg is not started and the camera is hidden from the UI
|
||||
# (except Camera Management). Re-enabling requires a Frigate restart.
|
||||
enabled: True
|
||||
# Optional: camera type used for some Frigate features (default: shown below)
|
||||
# Options are "generic" and "lpr"
|
||||
|
||||
@@ -23,7 +23,7 @@ In 0.14 and later, all of that is bundled into a single review item which starts
|
||||
|
||||
## Alerts and Detections
|
||||
|
||||
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate 0.14 categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
|
||||
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -130,3 +130,43 @@ By default a review item will be created if any `review -> alerts -> labels` and
|
||||
Because zones don't apply to audio, audio labels will always be marked as a detection by default.
|
||||
|
||||
:::
|
||||
|
||||
## Reviewing Motion
|
||||
|
||||
The Review page also can show periods of motion that didn't produce a tracked object, and provides a way to search past recordings for motion in a specific region. These tools complement the alerts and detections workflow above — see [Tuning Motion Detection](motion_detection.md) for how the underlying motion detector is configured.
|
||||
|
||||
### Motion Previews
|
||||
|
||||
The Motion Previews pane shows preview clips for periods of significant motion that did not produce a tracked object. It is useful for spotting things that motion detection picked up but object detection did not, which can help validate tuning or catch missed objects.
|
||||
|
||||
On the <NavPath path="Review > Motion" /> page, click the 3-dots menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
|
||||
|
||||
The pane provides a few controls:
|
||||
|
||||
- **Speed** — speeds up or slows down all of the preview clips at once.
|
||||
- **Dim** — controls how strongly non-motion areas are darkened by the heatmap overlay. Higher values increase motion area visibility.
|
||||
- **Filter** — opens a 16×16 grid overlaid on a snapshot of the camera. Select one or more cells to only show clips with motion in those regions. This is helpful for filtering out motion in areas like a busy street while keeping motion in your driveway.
|
||||
|
||||
Clicking a preview clip seeks the recording player to that timestamp so you can review the full footage.
|
||||
|
||||
### Motion Search
|
||||
|
||||
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
|
||||
|
||||
To start a search, click the 3-dots menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
||||
|
||||
1. Pick the camera and time range to scan.
|
||||
2. Draw a polygon on the camera frame to define the region of interest.
|
||||
3. Adjust the search parameters if needed:
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
|
||||
| **Minimum Change Area** | Minimum percentage of the region of interest that must change for a frame to be considered significant. Raise it to ignore small movements (leaves, distant motion); lower it when the object you care about only covers a small slice of the ROI. |
|
||||
| **Frame Skip** | Number of frames to skip between samples — at a camera recording 20 fps, a skip value of 20 takes motion samples roughly once per second. Higher values scan much faster and are usually the right choice; lower it only when you need to catch the exact appearance or disappearance of a fast-moving object. |
|
||||
| **Maximum Results** | Maximum number of matching timestamps to return. |
|
||||
| **Parallel mode** | Process multiple recording segments in parallel. Speeds up large time ranges at the cost of higher CPU usage. |
|
||||
|
||||
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
|
||||
|
||||
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
|
||||
|
||||
@@ -144,7 +144,7 @@ At this point you should be able to start Frigate and a basic config will be cre
|
||||
|
||||
### Step 2: Add a camera
|
||||
|
||||
Click the **Add Camera** button in <NavPath path="Settings > Camera configuration > Management" /> to use the camera setup wizard to get your first camera added into Frigate.
|
||||
Click the **Add Camera** button in <NavPath path="Settings > Global configuration > Camera management" /> to use the camera setup wizard to get your first camera added into Frigate.
|
||||
|
||||
### Step 3: Configure hardware acceleration (recommended)
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ For clips to be castable to media devices, audio is required and may need to be
|
||||
|
||||
## Camera API
|
||||
|
||||
To disable a camera dynamically
|
||||
To turn a camera off (pauses Frigate's processing of the stream; does not persist across Frigate restarts; see [Camera state](/configuration/live#camera-state)):
|
||||
|
||||
```
|
||||
action: camera.turn_off
|
||||
@@ -204,7 +204,7 @@ target:
|
||||
entity_id: camera.back_deck_cam # your Frigate camera entity ID
|
||||
```
|
||||
|
||||
To enable a camera that has been disabled dynamically
|
||||
To turn a camera back on:
|
||||
|
||||
```
|
||||
action: camera.turn_on
|
||||
@@ -213,6 +213,12 @@ target:
|
||||
entity_id: camera.back_deck_cam # your Frigate camera entity ID
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
These actions toggle Frigate's runtime On/Off state. To permanently disable a camera, set its status to **Disabled** in **Settings → Camera Management** in the Frigate UI.
|
||||
|
||||
:::
|
||||
|
||||
## Notification API
|
||||
|
||||
Many people do not want to expose Frigate to the web, so the integration creates some public API endpoints that can be used for notifications.
|
||||
|
||||
@@ -306,7 +306,7 @@ Publishes the current health status of each role that is enabled (`audio`, `dete
|
||||
|
||||
- `online`: Stream is running and being processed
|
||||
- `offline`: Stream is offline and is being restarted
|
||||
- `disabled`: Camera is currently disabled
|
||||
- `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.
|
||||
|
||||
### `frigate/<camera_name>/<object_name>`
|
||||
|
||||
@@ -368,11 +368,11 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o
|
||||
|
||||
### `frigate/<camera_name>/enabled/set`
|
||||
|
||||
Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is **not** persisted across Frigate restarts — the camera returns to the configured state on restart. To permanently disable a camera, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
|
||||
|
||||
### `frigate/<camera_name>/enabled/state`
|
||||
|
||||
Topic with current state of processing for a camera. Published values are `ON` and `OFF`.
|
||||
Topic with current runtime state of processing for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/detect/set`
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ Only one replay session can be active at a time. If a session is already running
|
||||
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
|
||||
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
|
||||
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
|
||||
- In cases where a detection is short and a replay may only be a small number of frames, it is recommended to manually add some padding before and after the detection so that the motion and object detectors have time to settle into the scene. Rather than starting Debug Replay from Explore, navigate to History for your camera, choose Debug Replay from the Actions menu, and click the "From Timeline" or "Custom" option.
|
||||
|
||||
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.
|
||||
|
||||
|
||||
@@ -750,6 +750,40 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
settings,
|
||||
)
|
||||
|
||||
# detect resize also republishes motion + objects so other
|
||||
# processes pick up the rebuilt masks, and fires refresh so
|
||||
# the camera maintainer recycles the camera process to pick
|
||||
# up the new ffmpeg cmd / SHM sizing
|
||||
if field == "detect":
|
||||
cam_cfg = config.cameras.get(camera)
|
||||
if cam_cfg is not None:
|
||||
if cam_cfg.motion is not None:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.motion, camera
|
||||
),
|
||||
cam_cfg.motion,
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.objects, camera
|
||||
),
|
||||
cam_cfg.objects,
|
||||
)
|
||||
if cam_cfg.zones:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.zones, camera
|
||||
),
|
||||
cam_cfg.zones,
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.refresh, camera
|
||||
),
|
||||
cam_cfg,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Config applied in-memory"},
|
||||
status_code=200,
|
||||
|
||||
+20
-1
@@ -547,9 +547,21 @@ async def _execute_get_live_context(
|
||||
camera: str,
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
# Reject wildcards explicitly so models retry with a real camera name
|
||||
# instead of silently fanning out across every camera.
|
||||
if camera in ("*", "all"):
|
||||
return {
|
||||
"error": (
|
||||
"get_live_context requires a single camera name; wildcards "
|
||||
"are not supported. Call this tool once per camera."
|
||||
),
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
|
||||
if camera not in allowed_cameras:
|
||||
return {
|
||||
"error": f"Camera '{camera}' not found or access denied",
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
|
||||
if camera not in request.app.frigate_config.cameras:
|
||||
@@ -721,7 +733,14 @@ async def _execute_tool_internal(
|
||||
"Arguments: %s",
|
||||
json.dumps(arguments),
|
||||
)
|
||||
return {"error": "Camera parameter is required"}
|
||||
return {
|
||||
"error": (
|
||||
"get_live_context requires a single camera name; "
|
||||
"wildcards and empty values are not supported. "
|
||||
"Call this tool once per camera."
|
||||
),
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
return await _execute_get_live_context(request, camera, allowed_cameras)
|
||||
elif tool_name == "start_camera_watch":
|
||||
return await _execute_start_camera_watch(request, arguments)
|
||||
|
||||
@@ -14,6 +14,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Regions
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
|
||||
@@ -50,6 +51,7 @@ class CameraMaintainer(threading.Thread):
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.refresh,
|
||||
],
|
||||
)
|
||||
self.shm_count = self.__calculate_shm_frame_count()
|
||||
@@ -202,6 +204,25 @@ class CameraMaintainer(threading.Thread):
|
||||
capture_process.terminate()
|
||||
capture_process.join()
|
||||
|
||||
def __unlink_camera_frame_slots(self, camera: str) -> None:
|
||||
"""Drop the camera's per-frame YUV SHM segments from this
|
||||
process's frame_manager and unlink them at the OS level.
|
||||
|
||||
Safe to call after the camera's capture/processor subprocesses
|
||||
have been joined — they no longer hold mappings, so unlink frees
|
||||
the segments immediately. Other long-lived processes that opened
|
||||
these slots will continue using their existing mappings until
|
||||
they call frame_manager.get with a shape that no longer fits
|
||||
(the get path drops and reopens stale refs).
|
||||
"""
|
||||
prefix = f"{camera}_frame"
|
||||
names = [n for n in list(self.frame_manager.shm_store) if n.startswith(prefix)]
|
||||
for name in names:
|
||||
try:
|
||||
self.frame_manager.delete(name)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not unlink SHM %s: %s", name, exc)
|
||||
|
||||
def __stop_camera_process(self, camera: str) -> None:
|
||||
camera_process = self.camera_processes.get(camera)
|
||||
if camera_process is not None:
|
||||
@@ -253,12 +274,45 @@ class CameraMaintainer(threading.Thread):
|
||||
for camera in updated_cameras:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
self.__unlink_camera_frame_slots(camera)
|
||||
self.capture_processes.pop(camera, None)
|
||||
self.camera_processes.pop(camera, None)
|
||||
self.camera_stop_events.pop(camera, None)
|
||||
self.region_grids.pop(camera, None)
|
||||
self.camera_metrics.pop(camera, None)
|
||||
self.ptz_metrics.pop(camera, None)
|
||||
elif update_type == CameraConfigUpdateEnum.refresh.name:
|
||||
# Recycle replay cameras so detect width/height/fps
|
||||
# propagate through ffmpeg args, SHM sizing, and the
|
||||
# region grid. Regular cameras detect change still
|
||||
# requires a full restart.
|
||||
for camera in updated_cameras:
|
||||
if not camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||
continue
|
||||
|
||||
new_config = self.update_subscriber.camera_configs.get(camera)
|
||||
if new_config is None:
|
||||
# remove arrived in the same batch
|
||||
continue
|
||||
|
||||
if (
|
||||
camera not in self.camera_processes
|
||||
and camera not in self.capture_processes
|
||||
):
|
||||
continue
|
||||
|
||||
# rebuild ffmpeg cmds on the shared config so the
|
||||
# new subprocesses spawn with current args
|
||||
new_config.recreate_ffmpeg_cmds()
|
||||
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
self.__unlink_camera_frame_slots(camera)
|
||||
self.capture_processes.pop(camera, None)
|
||||
self.camera_processes.pop(camera, None)
|
||||
|
||||
self.__start_camera_processor(camera, new_config, runtime=True)
|
||||
self.__start_camera_capture(camera, new_config, runtime=True)
|
||||
|
||||
# ensure the capture processes are done
|
||||
for camera in self.capture_processes.keys():
|
||||
|
||||
+52
-8
@@ -45,6 +45,7 @@ class CameraState:
|
||||
self.frame_cache: dict[float, dict[str, Any]] = {}
|
||||
self.zone_objects: defaultdict[str, list[Any]] = defaultdict(list)
|
||||
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
|
||||
self._last_frame_shape: tuple[int, int] = self.camera_config.frame_shape_yuv
|
||||
self.current_frame_lock = threading.Lock()
|
||||
self.current_frame_time = 0.0
|
||||
self.motion_boxes: list[tuple[int, int, int, int]] = []
|
||||
@@ -303,6 +304,42 @@ class CameraState:
|
||||
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
|
||||
self.callbacks[event_type].append(callback)
|
||||
|
||||
def _discard_stale_resolution_state(
|
||||
self, current_detections: dict[str, dict[str, Any]]
|
||||
) -> bool:
|
||||
"""Drop tracked state when the camera's detect resolution has
|
||||
changed, and signal the caller to skip this batch if it contains
|
||||
out-of-bounds boxes from the pre-recycle detect process.
|
||||
|
||||
Returns True when the batch should be skipped entirely.
|
||||
"""
|
||||
# detect resolution changed — drop tracked state so old-grid
|
||||
# boxes don't leak through end-callbacks
|
||||
current_shape = self.camera_config.frame_shape_yuv
|
||||
if current_shape != self._last_frame_shape:
|
||||
logger.debug(
|
||||
f"{self.name}: detect resolution changed {self._last_frame_shape} -> {current_shape}, dropping tracked state"
|
||||
)
|
||||
with self.current_frame_lock:
|
||||
self.tracked_objects.clear()
|
||||
self.motion_boxes = []
|
||||
self.regions = []
|
||||
self._last_frame_shape = current_shape
|
||||
|
||||
# drop in-flight batches from the pre-recycle detect process
|
||||
# whose boxes exceed the current detect resolution
|
||||
detect = self.camera_config.detect
|
||||
if detect.width is not None and detect.height is not None:
|
||||
for obj in current_detections.values():
|
||||
box = obj.get("box")
|
||||
if box and (box[2] > detect.width or box[3] > detect.height):
|
||||
logger.debug(
|
||||
f"{self.name}: dropping stale-resolution detection batch (box {box} exceeds {detect.width}x{detect.height})"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def update(
|
||||
self,
|
||||
frame_name: str,
|
||||
@@ -311,6 +348,9 @@ class CameraState:
|
||||
motion_boxes: list[tuple[int, int, int, int]],
|
||||
regions: list[tuple[int, int, int, int]],
|
||||
) -> None:
|
||||
if self._discard_stale_resolution_state(current_detections):
|
||||
return
|
||||
|
||||
current_frame = self.frame_manager.get(
|
||||
frame_name, self.camera_config.frame_shape_yuv
|
||||
)
|
||||
@@ -332,14 +372,18 @@ class CameraState:
|
||||
current_detections[id],
|
||||
)
|
||||
|
||||
# add initial frame to frame cache
|
||||
logger.debug(
|
||||
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
|
||||
)
|
||||
self.frame_cache[frame_time] = {
|
||||
"frame": np.copy(current_frame), # type: ignore[arg-type]
|
||||
"object_id": id,
|
||||
}
|
||||
# Skip caching when the frame buffer isn't readable — e.g.
|
||||
# frame_manager.get returned None because the SHM segment was
|
||||
# unlinked or hasn't been recreated yet during a camera
|
||||
# add/remove cycle.
|
||||
if current_frame is not None:
|
||||
logger.debug(
|
||||
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
|
||||
)
|
||||
self.frame_cache[frame_time] = {
|
||||
"frame": np.copy(current_frame),
|
||||
"object_id": id,
|
||||
}
|
||||
|
||||
# save initial thumbnail data and best object
|
||||
thumbnail_data = {
|
||||
|
||||
@@ -26,6 +26,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
object_genai = "object_genai"
|
||||
onvif = "onvif"
|
||||
record = "record"
|
||||
refresh = "refresh" # signals the camera maintainer to recycle the camera process
|
||||
remove = "remove" # for removing a camera
|
||||
review = "review"
|
||||
review_genai = "review_genai"
|
||||
@@ -84,8 +85,8 @@ class CameraConfigUpdateSubscriber:
|
||||
self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any
|
||||
) -> None:
|
||||
if update_type == CameraConfigUpdateEnum.add:
|
||||
self.config.cameras[camera] = updated_config
|
||||
self.camera_configs[camera] = updated_config
|
||||
shared = self.config.cameras.setdefault(camera, updated_config)
|
||||
self.camera_configs[camera] = shared
|
||||
return
|
||||
elif update_type == CameraConfigUpdateEnum.remove:
|
||||
self.config.cameras.pop(camera, None)
|
||||
|
||||
@@ -326,6 +326,47 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
|
||||
)
|
||||
|
||||
|
||||
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
|
||||
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
|
||||
for profile_name, profile in camera_config.profiles.items():
|
||||
if profile.zones:
|
||||
for zone_name in profile.zones:
|
||||
if zone_name not in camera_config.zones:
|
||||
raise ValueError(
|
||||
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
|
||||
f"zone '{zone_name}' that does not exist on the base config"
|
||||
)
|
||||
|
||||
if profile.motion and profile.motion.mask:
|
||||
for mask_name in profile.motion.mask:
|
||||
if mask_name not in camera_config.motion.mask:
|
||||
raise ValueError(
|
||||
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
|
||||
f"motion mask '{mask_name}' that does not exist on the base config"
|
||||
)
|
||||
|
||||
if profile.objects:
|
||||
for mask_name in profile.objects.mask or {}:
|
||||
if mask_name not in (camera_config.objects.mask or {}):
|
||||
raise ValueError(
|
||||
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
|
||||
f"object mask '{mask_name}' that does not exist on the base config"
|
||||
)
|
||||
for label, filter_config in (profile.objects.filters or {}).items():
|
||||
base_filter = (camera_config.objects.filters or {}).get(label)
|
||||
profile_filter_masks = (
|
||||
filter_config.mask if filter_config else None
|
||||
) or {}
|
||||
base_filter_masks = (base_filter.mask if base_filter else None) or {}
|
||||
for mask_name in profile_filter_masks:
|
||||
if mask_name not in base_filter_masks:
|
||||
raise ValueError(
|
||||
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
|
||||
f"object mask '{mask_name}' for '{label}' that does not exist "
|
||||
f"on the base config"
|
||||
)
|
||||
|
||||
|
||||
def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
|
||||
"""Verify that required_zones are specified when autotracking is enabled."""
|
||||
if (
|
||||
@@ -952,6 +993,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
||||
verify_zone_objects_are_tracked(camera_config)
|
||||
verify_required_zones_exist(camera_config)
|
||||
verify_profile_overrides_match_base(camera_config)
|
||||
verify_autotrack_zones(camera_config)
|
||||
verify_motion_and_detect(camera_config)
|
||||
verify_objects_track(camera_config, labelmap_objects)
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any, AsyncGenerator, Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from pydantic import ValidationError
|
||||
@@ -359,6 +359,41 @@ class GenAIClient:
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
async def chat_with_tools_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Streaming counterpart to `chat_with_tools`.
|
||||
|
||||
Yields ``(kind, value)`` tuples where ``kind`` is one of:
|
||||
- 'content_delta': value is a string fragment of the answer
|
||||
- 'reasoning_delta': value is a string fragment of the reasoning
|
||||
trace (emitted before content for thinking models)
|
||||
- 'stats': value is a usage stats dict
|
||||
- 'message': value is the final dict shape described in
|
||||
`chat_with_tools`
|
||||
|
||||
Argument semantics — including ``enable_thinking`` — match
|
||||
`chat_with_tools`. Providers that don't support streaming should
|
||||
override this and yield an error 'message' event.
|
||||
"""
|
||||
logger.warning(
|
||||
f"{self.__class__.__name__} does not support chat_with_tools_stream. "
|
||||
"This method should be overridden by the provider implementation."
|
||||
)
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def load_providers() -> None:
|
||||
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
|
||||
|
||||
@@ -401,11 +401,14 @@ class GeminiClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
Implements streaming function calling/tool usage for Gemini models.
|
||||
``enable_thinking`` is accepted for interface parity; Gemini configures
|
||||
thinking at the model level, so it is ignored here.
|
||||
"""
|
||||
try:
|
||||
# Convert messages to Gemini format
|
||||
|
||||
@@ -309,11 +309,15 @@ class OpenAIClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
Implements streaming function calling/tool usage for OpenAI models.
|
||||
The OpenAI chat completions API does not expose a per-request thinking
|
||||
toggle, so ``enable_thinking`` is accepted for interface parity and
|
||||
ignored.
|
||||
"""
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
|
||||
@@ -518,16 +518,21 @@ def get_tool_definitions(
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
"Get the current live image and detection information for a camera: objects being tracked, "
|
||||
"Get the current live image and detection information for a single camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"Call this when answering questions about what is happening right now on a specific camera."
|
||||
"Call this when answering questions about what is happening right now on a specific camera. "
|
||||
"Operates on one camera at a time; call the tool again for each additional camera. "
|
||||
"Wildcards and empty values are not accepted."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to get live context for.",
|
||||
"description": (
|
||||
"Exact name of a single camera to get live context for. "
|
||||
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["camera"],
|
||||
|
||||
@@ -579,7 +579,9 @@ class RecordingExporter(threading.Thread):
|
||||
else:
|
||||
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||
chapter_args = (
|
||||
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
|
||||
f" -i {chapters_path} -map 0 -dn -map_metadata 1"
|
||||
if chapters_path
|
||||
else ""
|
||||
)
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for CameraMaintainer SHM cleanup on camera remove.
|
||||
|
||||
Regression coverage for the case where a camera is removed and then a
|
||||
new camera is added with the same name. Without unlinking the per-frame
|
||||
YUV SHM slots, the maintainer's frame_manager.create call hits
|
||||
FileExistsError and falls back to reopening the existing segment at the
|
||||
*old* size, which the new ffmpeg process then writes mismatched-size
|
||||
frames into.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.camera.maintainer import CameraMaintainer
|
||||
|
||||
|
||||
class TestMaintainerUnlinkFrameSlotsOnRemove(unittest.TestCase):
|
||||
def _make_maintainer(self) -> CameraMaintainer:
|
||||
"""Build a maintainer without invoking __init__ (avoids needing real
|
||||
FrigateConfig, queues, multiprocessing manager, etc.). We're only
|
||||
exercising the SHM-cleanup helper, so the surrounding init is
|
||||
irrelevant."""
|
||||
maintainer = CameraMaintainer.__new__(CameraMaintainer)
|
||||
maintainer.frame_manager = MagicMock()
|
||||
return maintainer
|
||||
|
||||
def test_unlinks_only_segments_with_matching_prefix(self) -> None:
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {
|
||||
"front_frame0": object(),
|
||||
"front_frame1": object(),
|
||||
"front_frame2": object(),
|
||||
# Different camera; must not be touched.
|
||||
"side_frame0": object(),
|
||||
# Detector input/output buffers are sized by the model and
|
||||
# cached by the long-lived DetectorRunner — must not be
|
||||
# touched even when their owning camera is removed.
|
||||
"front": object(),
|
||||
"out-front": object(),
|
||||
}
|
||||
|
||||
# __name-mangled access from outside the class.
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
deleted = [c.args[0] for c in maintainer.frame_manager.delete.call_args_list]
|
||||
self.assertEqual(
|
||||
sorted(deleted),
|
||||
["front_frame0", "front_frame1", "front_frame2"],
|
||||
)
|
||||
|
||||
def test_handles_camera_with_no_slots(self) -> None:
|
||||
"""Cameras that were removed before any frame slot was ever
|
||||
created (e.g. cancelled during preparing_clip) should be a no-op."""
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {"other_frame0": object()}
|
||||
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
maintainer.frame_manager.delete.assert_not_called()
|
||||
|
||||
def test_swallows_delete_errors(self) -> None:
|
||||
"""Unlink failures shouldn't abort the remove loop — best-effort."""
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {
|
||||
"front_frame0": object(),
|
||||
"front_frame1": object(),
|
||||
}
|
||||
maintainer.frame_manager.delete.side_effect = OSError("simulated")
|
||||
|
||||
# Both slots are attempted; the OSError on the first doesn't
|
||||
# prevent the second from being tried.
|
||||
with patch("frigate.camera.maintainer.logger"):
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
self.assertEqual(maintainer.frame_manager.delete.call_count, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -178,6 +178,141 @@ class TestCameraProfileConfig(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**config_data)
|
||||
|
||||
def test_profile_zone_without_base_rejected(self):
|
||||
"""Profile defining a zone not present on the base camera is rejected."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"zones": {
|
||||
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
|
||||
},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"phantom": {
|
||||
"coordinates": "0,0,50,0,50,50,0,50",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
FrigateConfig(**config_data)
|
||||
self.assertIn("phantom", str(ctx.exception))
|
||||
|
||||
def test_profile_motion_mask_without_base_rejected(self):
|
||||
"""Profile defining a motion mask not present on the base camera is rejected."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"motion": {
|
||||
"mask": {
|
||||
"base_mask": {
|
||||
"coordinates": "0,0,100,0,100,100,0,100",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"motion": {
|
||||
"mask": {
|
||||
"phantom_mask": {
|
||||
"coordinates": "0,0,50,0,50,50,0,50",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
FrigateConfig(**config_data)
|
||||
self.assertIn("phantom_mask", str(ctx.exception))
|
||||
|
||||
def test_profile_overrides_matching_base_accepted(self):
|
||||
"""Profile overrides that reference existing base zones/masks parse cleanly."""
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"zones": {
|
||||
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
|
||||
},
|
||||
"motion": {
|
||||
"mask": {
|
||||
"tree": {
|
||||
"coordinates": "0,0,100,0,100,100,0,100",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"front_yard": {
|
||||
"coordinates": "0,0,50,0,50,50,0,50",
|
||||
"inertia": 5,
|
||||
},
|
||||
},
|
||||
"motion": {
|
||||
"mask": {
|
||||
"tree": {
|
||||
"coordinates": "0,0,75,0,75,75,0,75",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
config = FrigateConfig(**config_data)
|
||||
assert "armed" in config.cameras["front"].profiles
|
||||
|
||||
|
||||
class TestProfileInConfig(unittest.TestCase):
|
||||
"""Test that profiles parse correctly in FrigateConfig."""
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests for SharedMemoryFrameManager cache invalidation.
|
||||
|
||||
Covers the case where a SHM segment is unlinked and recreated at a
|
||||
different size across a camera add/remove cycle while a long-lived
|
||||
in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
|
||||
the old, smaller segment.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.util.image import SharedMemoryFrameManager
|
||||
|
||||
|
||||
def _fake_shm(size: int) -> SimpleNamespace:
|
||||
"""A minimal stand-in for UntrackedSharedMemory with .size and .buf."""
|
||||
return SimpleNamespace(size=size, buf=bytearray(size), close=lambda: None)
|
||||
|
||||
|
||||
class TestSharedMemoryFrameManagerGet(unittest.TestCase):
|
||||
def test_get_reopens_when_cached_segment_is_smaller_than_shape(self) -> None:
|
||||
"""A cached ref to an older smaller segment must be dropped and the
|
||||
current (correctly sized) segment reopened. Without this, np.ndarray
|
||||
would raise "buffer is too small for requested array" when the
|
||||
in-memory cache pointed at an old SHM after a same-name resize."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
small = _fake_shm(size=100)
|
||||
current = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = small
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (50, 50))
|
||||
self.assertIs(manager.shm_store["cam_frame0"], current)
|
||||
|
||||
def test_get_reopens_when_cached_segment_is_larger_than_shape(self) -> None:
|
||||
"""Symmetric to the smaller-cache case: when detect resolution drops,
|
||||
the SHM is unlinked and recreated at a smaller size. A cached ref to
|
||||
the old, larger segment still satisfies any size check but points at
|
||||
an orphaned inode whose stale bytes get reinterpreted at the new
|
||||
shape — producing miscolored, distorted YUV frames downstream. Drop
|
||||
the cache so we reopen by name and bind to the current segment."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
old_large = _fake_shm(size=10_000)
|
||||
current = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = old_large
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (50, 50))
|
||||
self.assertIs(manager.shm_store["cam_frame0"], current)
|
||||
|
||||
def test_get_keeps_cached_segment_when_size_matches(self) -> None:
|
||||
"""Don't pay the reopen cost when the cached ref is the right size."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
cached = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = cached
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
untracked_shm_cls.assert_not_called()
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertIs(manager.shm_store["cam_frame0"], cached)
|
||||
|
||||
def test_get_opens_fresh_when_no_cache_entry(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
fresh = _fake_shm(size=2_500)
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=fresh):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertIs(manager.shm_store["cam_frame0"], fresh)
|
||||
|
||||
def test_get_returns_none_when_segment_missing(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
with patch(
|
||||
"frigate.util.image.UntrackedSharedMemory",
|
||||
side_effect=FileNotFoundError,
|
||||
):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNone(arr)
|
||||
|
||||
def test_get_returns_none_when_reopened_segment_is_still_too_small(self) -> None:
|
||||
"""Race during a same-name SHM recreate: cache is stale, we reopen
|
||||
by name, but the maintainer hasn't allocated the new segment yet —
|
||||
the reopened ref is also too small. Skip the frame (return None)
|
||||
rather than crash on np.ndarray."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
small_cached = _fake_shm(size=100)
|
||||
still_small_after_reopen = _fake_shm(size=100)
|
||||
manager.shm_store["cam_frame0"] = small_cached
|
||||
|
||||
with patch(
|
||||
"frigate.util.image.UntrackedSharedMemory",
|
||||
return_value=still_small_after_reopen,
|
||||
):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNone(arr)
|
||||
# Don't cache the too-small reopened ref — next call will re-open
|
||||
# once the maintainer has finished recreating the segment.
|
||||
self.assertNotIn("cam_frame0", manager.shm_store)
|
||||
|
||||
def test_get_handles_n_dimensional_shape(self) -> None:
|
||||
"""np.prod must be used (not raw multiplication) for tuple shapes."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
# YUV-shaped frame: (height * 3/2, width) for 1920x1080 = 3,110,400
|
||||
big_enough = _fake_shm(size=3_110_400)
|
||||
manager.shm_store["cam_frame0"] = big_enough
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
|
||||
arr = manager.get("cam_frame0", (1620, 1920))
|
||||
untracked_shm_cls.assert_not_called()
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (1620, 1920))
|
||||
|
||||
|
||||
class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
|
||||
"""End-to-end-style: simulates the full unlink-and-recreate cycle."""
|
||||
|
||||
def test_segment_grows_then_get_succeeds(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
# Phase 1: existing camera at 320x240 YUV — 320 * 240 * 1.5 = 115_200
|
||||
small = _fake_shm(size=115_200)
|
||||
manager.shm_store["cam_frame0"] = small
|
||||
arr_small = np.ndarray((360, 320), dtype=np.uint8, buffer=small.buf)
|
||||
self.assertEqual(arr_small.shape, (360, 320))
|
||||
|
||||
# Phase 2: restart at 1920x1080 — new SHM segment, larger size.
|
||||
large = _fake_shm(size=3_110_400)
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=large):
|
||||
arr_large = manager.get("cam_frame0", (1620, 1920))
|
||||
|
||||
self.assertIsNotNone(arr_large)
|
||||
self.assertEqual(arr_large.shape, (1620, 1920))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -788,6 +788,45 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
|
||||
)
|
||||
camera_config.objects = new_objects
|
||||
|
||||
elif section == "detect":
|
||||
# apply detect first so frame_shape reflects the new resolution
|
||||
# before we rebuild mask-dependent runtime configs below
|
||||
merged = deep_merge(current.model_dump(), update, override=True)
|
||||
camera_config.detect = current.__class__.model_validate(merged)
|
||||
|
||||
new_frame_shape = camera_config.frame_shape
|
||||
|
||||
# rebuild motion's rasterized_mask at the new frame_shape
|
||||
if camera_config.motion is not None:
|
||||
camera_config.motion = RuntimeMotionConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
**camera_config.motion.model_dump(exclude_unset=True),
|
||||
)
|
||||
|
||||
# rebuild per-object filter masks at the new frame_shape
|
||||
for obj_name, filt in camera_config.objects.filters.items():
|
||||
merged_mask = dict(filt.mask)
|
||||
if camera_config.objects.mask:
|
||||
for gid, gmask in camera_config.objects.mask.items():
|
||||
merged_mask[f"global_{gid}"] = gmask
|
||||
|
||||
camera_config.objects.filters[obj_name] = RuntimeFilterConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
mask=merged_mask,
|
||||
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
|
||||
)
|
||||
|
||||
# Regenerate zone contours and per-zone filter masks at the new
|
||||
# frame_shape so zone outlines and membership stay relative
|
||||
for zone in camera_config.zones.values():
|
||||
if zone.filters:
|
||||
for zone_obj_name, zone_filter in zone.filters.items():
|
||||
zone.filters[zone_obj_name] = RuntimeFilterConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
**zone_filter.model_dump(exclude_unset=True),
|
||||
)
|
||||
zone.generate_contour(new_frame_shape)
|
||||
|
||||
else:
|
||||
merged = deep_merge(current.model_dump(), update, override=True)
|
||||
setattr(camera_config, section, current.__class__.model_validate(merged))
|
||||
|
||||
+18
-3
@@ -1089,10 +1089,25 @@ class SharedMemoryFrameManager(FrameManager):
|
||||
|
||||
def get(self, name: str, shape) -> Optional[np.ndarray]:
|
||||
try:
|
||||
if name in self.shm_store:
|
||||
shm = self.shm_store[name]
|
||||
else:
|
||||
required = int(np.prod(shape))
|
||||
shm = self.shm_store.get(name)
|
||||
if shm is not None and shm.size != required:
|
||||
# stale cached ref from a same-name recreate — drop and reopen
|
||||
try:
|
||||
shm.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.shm_store.pop(name, None)
|
||||
shm = None
|
||||
if shm is None:
|
||||
shm = UntrackedSharedMemory(name=name)
|
||||
if shm.size != required:
|
||||
# mid-recreate: OS segment doesn't match shape yet; skip
|
||||
try:
|
||||
shm.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
self.shm_store[name] = shm
|
||||
return np.ndarray(shape, dtype=np.uint8, buffer=shm.buf)
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -478,7 +478,7 @@ def get_intel_gpu_stats(
|
||||
overall_pct = min(100.0, compute_pct + dec_pct)
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"name": names.get(pdev) or f"Intel GPU {pdev}",
|
||||
"name": names.get(pdev) or "Intel iGPU",
|
||||
"vendor": "intel",
|
||||
"gpu": f"{round(overall_pct, 2)}%",
|
||||
"mem": "-%",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"title": "Stream Offline",
|
||||
"desc": "No frames have been received on the {{cameraName}} <code>detect</code> stream, check error logs"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled",
|
||||
"cameraOff": "Camera is off",
|
||||
"stats": {
|
||||
"streamType": {
|
||||
"title": "Stream Type:",
|
||||
|
||||
@@ -28,5 +28,8 @@
|
||||
"detectRequired": "At least one input stream must be assigned the 'detect' role.",
|
||||
"hwaccelDetectOnly": "Only the input stream with the detect role can define hardware acceleration arguments."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"dimensionMustBeEven": "Must be an even number."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
"presets": "PTZ camera presets"
|
||||
},
|
||||
"camera": {
|
||||
"enable": "Enable Camera",
|
||||
"disable": "Disable Camera"
|
||||
"turnOn": "Turn Camera On",
|
||||
"turnOff": "Turn Camera Off"
|
||||
},
|
||||
"muteCameras": {
|
||||
"enable": "Mute All Cameras",
|
||||
@@ -153,7 +153,7 @@
|
||||
},
|
||||
"cameraSettings": {
|
||||
"title": "{{camera}} Settings",
|
||||
"cameraEnabled": "Camera Enabled",
|
||||
"camera": "Camera",
|
||||
"objectDetection": "Object Detection",
|
||||
"recording": "Recording",
|
||||
"snapshots": "Snapshots",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"cameraUi": "Camera UI",
|
||||
"cameraTimestampStyle": "Timestamp style",
|
||||
"cameraMqtt": "Camera MQTT",
|
||||
"cameraManagement": "Management",
|
||||
"cameraManagement": "Camera management",
|
||||
"cameraReview": "Review",
|
||||
"masksAndZones": "Masks / Zones",
|
||||
"motionTuner": "Motion tuner",
|
||||
@@ -457,7 +457,7 @@
|
||||
},
|
||||
"cameraManagement": {
|
||||
"title": "Manage Cameras",
|
||||
"description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
|
||||
"description": "Add, edit, and delete cameras, control the state of each camera, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
|
||||
"addCamera": "Add New Camera",
|
||||
"deleteCamera": "Delete Camera",
|
||||
"deleteCameraDialog": {
|
||||
@@ -475,12 +475,17 @@
|
||||
"selectCamera": "Select a Camera",
|
||||
"backToSettings": "Back to Camera Settings",
|
||||
"streams": {
|
||||
"title": "Enable / Disable Cameras",
|
||||
"enableLabel": "Enabled cameras",
|
||||
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em><br /><br />Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.",
|
||||
"disableLabel": "Disabled cameras",
|
||||
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
|
||||
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
|
||||
"title": "Camera State and Details",
|
||||
"label": "Camera state",
|
||||
"description": "Set the operating state for each camera.<br /><br /><strong>On</strong>: streams are processed normally.<br /><strong>Off</strong>: temporarily pauses processing. Does not persist across Frigate restarts.<br /><strong>Disabled</strong>: stops processing and saves the change to your configuration. A restart is required to re-enable a disabled camera.<br /><br /><em>Note: Disabling does not affect go2rtc restreams.</em><br /><br />Drag the handle to reorder active cameras as they appear throughout the UI, including the Live dashboard and camera selection dropdowns.",
|
||||
"disabledSubheading": "Disabled in configuration",
|
||||
"status": {
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"enableSuccess": "Enabled {{cameraName}}. Restart Frigate to apply.",
|
||||
"disableSuccess": "Disabled {{cameraName}} and saved to configuration.",
|
||||
"reorderHandle": "Drag to reorder",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved",
|
||||
@@ -527,10 +532,10 @@
|
||||
"profiles": {
|
||||
"title": "Profile Camera Overrides",
|
||||
"selectLabel": "Select profile",
|
||||
"description": "Configure which cameras are enabled or disabled when a profile is activated. Cameras set to \"Inherit\" keep their base enabled state.",
|
||||
"description": "Configure which cameras are turned on or off when a profile is activated. Cameras set to \"Inherit\" keep their default state.",
|
||||
"inherit": "Inherit",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
},
|
||||
"cameraType": {
|
||||
"title": "Camera Type",
|
||||
@@ -1543,6 +1548,9 @@
|
||||
"builtIn": "Built-in Models",
|
||||
"genaiProviders": "GenAI Providers"
|
||||
},
|
||||
"semanticSearchModelSize": {
|
||||
"notApplicable": "Not applicable for GenAI providers"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review Settings"
|
||||
},
|
||||
@@ -1791,7 +1799,9 @@
|
||||
},
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function."
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
|
||||
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
|
||||
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image."
|
||||
},
|
||||
"objects": {
|
||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
import type { FieldConditionalMessage } from "./section-configs/types";
|
||||
|
||||
// Provides currently-active field messages to FieldTemplate without going
|
||||
// through RJSF's per-field uiSchema. RJSF caches state.uiSchema across renders
|
||||
// in a way that can leave stale ui:messages attached to a field when the
|
||||
// triggering condition flips back to false (see processPendingChange in
|
||||
// @rjsf/core Form.js — formData is updated immediately, uiSchema is not).
|
||||
// useContext re-runs consumers directly on provider value change, sidestepping
|
||||
// that staleness.
|
||||
export const FieldMessagesContext = createContext<FieldConditionalMessage[]>(
|
||||
[],
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
|
||||
// Mirrors the current section's in-flight form data so widgets can react
|
||||
// to changes that RJSF wouldn't otherwise re-render them for. RJSF's
|
||||
// Form memoizes SchemaField via deep equality and, in some transitions
|
||||
// (notably reverting a field to its saved value), can skip re-rendering
|
||||
// a widget even though the form data it depends on changed. useContext
|
||||
// re-runs consumers directly on every provider value update, sidestepping
|
||||
// that.
|
||||
export const LiveFormDataContext = createContext<ConfigSectionData | null>(
|
||||
null,
|
||||
);
|
||||
@@ -11,6 +11,50 @@ const detect: SectionConfigOverrides = {
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" && ctx.formData?.enabled === false,
|
||||
},
|
||||
{
|
||||
key: "detect-resolution-not-multiple-of-four",
|
||||
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
const width = ctx.formData?.width as number | null | undefined;
|
||||
const height = ctx.formData?.height as number | null | undefined;
|
||||
const isEvenButNotFour = (v: unknown) =>
|
||||
typeof v === "number" && v % 2 === 0 && v % 4 !== 0;
|
||||
return isEvenButNotFour(width) || isEvenButNotFour(height);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "detect-aspect-ratio-mismatch",
|
||||
messageKey: "configMessages.detect.aspectRatioMismatch",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
const newWidth = ctx.formData?.width as number | null | undefined;
|
||||
const newHeight = ctx.formData?.height as number | null | undefined;
|
||||
if (typeof newWidth !== "number" || typeof newHeight !== "number") {
|
||||
return false;
|
||||
}
|
||||
const saved =
|
||||
ctx.level === "camera"
|
||||
? ctx.fullCameraConfig?.detect
|
||||
: ctx.fullConfig?.detect;
|
||||
const savedWidth = saved?.width;
|
||||
const savedHeight = saved?.height;
|
||||
if (
|
||||
typeof savedWidth !== "number" ||
|
||||
typeof savedHeight !== "number" ||
|
||||
savedWidth <= 0 ||
|
||||
savedHeight <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (newWidth === savedWidth && newHeight === savedHeight) {
|
||||
return false;
|
||||
}
|
||||
const newRatio = newWidth / newHeight;
|
||||
const savedRatio = savedWidth / savedHeight;
|
||||
return Math.abs(newRatio - savedRatio) > 0.01;
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
@@ -72,6 +116,25 @@ const detect: SectionConfigOverrides = {
|
||||
"max_disappeared",
|
||||
],
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: ["width", "height", "fps"],
|
||||
fieldGroups: {
|
||||
resolution: ["width", "height", "fps"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled",
|
||||
"enabled_in_config",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
"interval",
|
||||
"threshold",
|
||||
"max_frames",
|
||||
],
|
||||
advancedFields: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default detect;
|
||||
|
||||
@@ -35,6 +35,7 @@ const semanticSearch: SectionConfigOverrides = {
|
||||
"ui:widget": "semanticSearchModel",
|
||||
},
|
||||
model_size: {
|
||||
"ui:widget": "semanticSearchModelSize",
|
||||
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import type { JsonObject } from "@/types/configForm";
|
||||
|
||||
export function validateDetectDimensions(
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
t: TFunction,
|
||||
): FormValidation {
|
||||
if (!isJsonObject(formData as JsonObject)) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const data = formData as JsonObject;
|
||||
const width = data.width;
|
||||
const height = data.height;
|
||||
|
||||
const widthErrors = errors.width as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
const heightErrors = errors.height as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
|
||||
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
|
||||
|
||||
if (typeof width === "number" && width % 2 !== 0) {
|
||||
widthErrors?.addError?.(message);
|
||||
}
|
||||
if (typeof height === "number" && height % 2 !== 0) {
|
||||
heightErrors?.addError?.(message);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { validateDetectDimensions } from "./detect";
|
||||
import { validateFfmpegInputRoles } from "./ffmpeg";
|
||||
import { validateProxyRoleHeader } from "./proxy";
|
||||
|
||||
@@ -19,6 +20,10 @@ export function getSectionValidation({
|
||||
level,
|
||||
t,
|
||||
}: SectionValidationOptions): SectionValidation | undefined {
|
||||
if (sectionPath === "detect") {
|
||||
return (formData, errors) => validateDetectDimensions(formData, errors, t);
|
||||
}
|
||||
|
||||
if (sectionPath === "ffmpeg" && level === "camera") {
|
||||
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ import type {
|
||||
} from "../section-configs/types";
|
||||
import { useConfigMessages } from "@/hooks/use-config-messages";
|
||||
import { ConfigMessageBanner } from "../ConfigMessageBanner";
|
||||
import { FieldMessagesContext } from "../FieldMessagesContext";
|
||||
import { LiveFormDataContext } from "../LiveFormDataContext";
|
||||
|
||||
export interface SectionConfig {
|
||||
/** Field ordering within the section */
|
||||
@@ -627,44 +629,6 @@ export function ConfigSection({
|
||||
messageContext,
|
||||
);
|
||||
|
||||
// Merge field-level conditional messages into uiSchema
|
||||
const effectiveUiSchema = useMemo(() => {
|
||||
if (activeFieldMessages.length === 0) return sectionConfig.uiSchema;
|
||||
const merged = { ...(sectionConfig.uiSchema ?? {}) };
|
||||
for (const msg of activeFieldMessages) {
|
||||
const segments = msg.field.split(".");
|
||||
// Navigate to the nested uiSchema node, shallow-cloning along the way
|
||||
let node = merged;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
node[seg] = { ...(node[seg] as Record<string, unknown>) };
|
||||
node = node[seg] as Record<string, unknown>;
|
||||
}
|
||||
const leafKey = segments[segments.length - 1];
|
||||
const existing = node[leafKey] as Record<string, unknown> | undefined;
|
||||
const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
|
||||
[]) as Array<{
|
||||
key: string;
|
||||
messageKey: string;
|
||||
severity: string;
|
||||
position?: string;
|
||||
}>;
|
||||
node[leafKey] = {
|
||||
...existing,
|
||||
"ui:messages": [
|
||||
...existingMessages,
|
||||
{
|
||||
key: msg.key,
|
||||
messageKey: msg.messageKey,
|
||||
severity: msg.severity,
|
||||
position: msg.position ?? "before",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}, [sectionConfig.uiSchema, activeFieldMessages]);
|
||||
|
||||
const currentOverrides = useMemo(() => {
|
||||
if (!currentFormData || typeof currentFormData !== "object") {
|
||||
return undefined;
|
||||
@@ -1034,59 +998,65 @@ export function ConfigSection({
|
||||
const sectionContent = (
|
||||
<div className="space-y-6">
|
||||
<ConfigMessageBanner messages={activeMessages} />
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
formData={currentFormData}
|
||||
onChange={handleChange}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={effectiveUiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
extraHasChanges,
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
// When rendering camera-level sections, provide the section path so
|
||||
// field templates can look up keys under the `config/cameras` namespace
|
||||
// When using a consolidated global namespace, keys are nested
|
||||
// under the section name (e.g., `audio.label`) so provide the
|
||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||
sectionI18nPrefix: sectionPath,
|
||||
t,
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
<FieldMessagesContext.Provider value={activeFieldMessages}>
|
||||
<LiveFormDataContext.Provider
|
||||
value={(currentFormData as ConfigSectionData | null) ?? null}
|
||||
>
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
formData={currentFormData}
|
||||
onChange={handleChange}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={sectionConfig.uiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
extraHasChanges,
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
// When rendering camera-level sections, provide the section path so
|
||||
// field templates can look up keys under the `config/cameras` namespace
|
||||
// When using a consolidated global namespace, keys are nested
|
||||
// under the section name (e.g., `audio.label`) so provide the
|
||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||
sectionI18nPrefix: sectionPath,
|
||||
t,
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
</LiveFormDataContext.Provider>
|
||||
</FieldMessagesContext.Provider>
|
||||
|
||||
{!embedded && (
|
||||
<div
|
||||
@@ -1288,7 +1258,12 @@ export function ConfigSection({
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Heading as="h4">{title}</Heading>
|
||||
<Heading
|
||||
as="h4"
|
||||
className={level === "replay" ? "text-base" : undefined}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
(profileOverridesSection || isOverridden) &&
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
|
||||
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
|
||||
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
@@ -86,6 +87,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
optionalField: OptionalFieldWidget,
|
||||
semanticSearchModel: SemanticSearchModelWidget,
|
||||
semanticSearchModelSize: SemanticSearchModelSizeWidget,
|
||||
onvifProfile: OnvifProfileWidget,
|
||||
},
|
||||
templates: {
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
getUiOptions,
|
||||
ADDITIONAL_PROPERTY_FLAG,
|
||||
} from "@rjsf/utils";
|
||||
import { ComponentType, ReactNode } from "react";
|
||||
import { ComponentType, ReactNode, useContext } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { FieldMessagesContext } from "../../FieldMessagesContext";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -95,6 +96,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
"views/settings",
|
||||
]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const allFieldMessages = useContext(FieldMessagesContext);
|
||||
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
@@ -384,21 +386,15 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const beforeContent = renderCustom(beforeSpec);
|
||||
const afterContent = renderCustom(afterSpec);
|
||||
|
||||
// Render conditional field messages from ui:messages
|
||||
const fieldMessageSpecs = uiSchema?.["ui:messages"] as
|
||||
| Array<{
|
||||
key: string;
|
||||
messageKey: string;
|
||||
severity: string;
|
||||
position?: string;
|
||||
}>
|
||||
| undefined;
|
||||
const beforeMessages = fieldMessageSpecs?.filter(
|
||||
// Read field-level conditional messages from FieldMessagesContext
|
||||
const fieldPathStr = pathSegments.join(".");
|
||||
const fieldMessageSpecs = allFieldMessages.filter(
|
||||
(m) => m.field === fieldPathStr,
|
||||
);
|
||||
const beforeMessages = fieldMessageSpecs.filter(
|
||||
(m) => (m.position ?? "before") === "before",
|
||||
);
|
||||
const afterMessages = fieldMessageSpecs?.filter(
|
||||
(m) => m.position === "after",
|
||||
);
|
||||
const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after");
|
||||
const beforeMessagesContent =
|
||||
beforeMessages && beforeMessages.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Disables model_size and shows "N/A" when a GenAI provider is selected.
|
||||
// Reads model via LiveFormDataContext so it re-runs even when RJSF's
|
||||
// SchemaField memoization would skip this widget.
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LiveFormDataContext } from "../../LiveFormDataContext";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
import { SelectWidget } from "./SelectWidget";
|
||||
|
||||
export function SemanticSearchModelSizeWidget(props: WidgetProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const liveFormData = useContext(LiveFormDataContext);
|
||||
const model = liveFormData?.model;
|
||||
const isProvider =
|
||||
typeof model === "string" &&
|
||||
model !== "" &&
|
||||
model !== "jinav1" &&
|
||||
model !== "jinav2";
|
||||
|
||||
// Clear model_size while on a provider (buildOverrides converts to ""
|
||||
// which the backend treats as "remove"). Restore the schema default
|
||||
// when returning to a Jina model so the field isn't left empty.
|
||||
const { value, onChange, schema } = props;
|
||||
const schemaDefault = schema?.default as string | undefined;
|
||||
useEffect(() => {
|
||||
if (isProvider && value !== undefined) {
|
||||
onChange(undefined);
|
||||
} else if (!isProvider && value === undefined && schemaDefault) {
|
||||
onChange(schemaDefault);
|
||||
}
|
||||
}, [isProvider, value, onChange, schemaDefault]);
|
||||
|
||||
if (isProvider) {
|
||||
const fieldClassName = getSizedFieldClassName(props.options ?? {}, "sm");
|
||||
return (
|
||||
<Select value="" disabled>
|
||||
<SelectTrigger className={fieldClassName}>
|
||||
<SelectValue
|
||||
placeholder={t("configForm.semanticSearchModelSize.notApplicable", {
|
||||
defaultValue: "Not applicable for GenAI providers",
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return <SelectWidget {...props} />;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
||||
placeholderName?: string;
|
||||
placeholderId?: string;
|
||||
idVisible?: boolean;
|
||||
idDisabled?: boolean;
|
||||
};
|
||||
|
||||
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
@@ -41,6 +42,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
placeholderName,
|
||||
placeholderId,
|
||||
idVisible,
|
||||
idDisabled,
|
||||
}: NameAndIdFieldsProps<T>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { watch, setValue, trigger, formState } = useFormContext<T>();
|
||||
@@ -59,6 +61,9 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
const effectiveProcessId = processId || defaultProcessId;
|
||||
|
||||
useEffect(() => {
|
||||
if (idDisabled) {
|
||||
return;
|
||||
}
|
||||
const subscription = watch((value, { name }) => {
|
||||
if (name === nameField) {
|
||||
hasUserTypedRef.current = true;
|
||||
@@ -68,7 +73,15 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
|
||||
}, [
|
||||
watch,
|
||||
setValue,
|
||||
trigger,
|
||||
nameField,
|
||||
idField,
|
||||
effectiveProcessId,
|
||||
idDisabled,
|
||||
]);
|
||||
|
||||
// Auto-expand if there's an error on the ID field after user has typed
|
||||
useEffect(() => {
|
||||
@@ -123,6 +136,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={placeholderId}
|
||||
disabled={idDisabled}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -320,7 +320,7 @@ export default function LiveContextMenu({
|
||||
onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")}
|
||||
>
|
||||
<div className="text-primary">
|
||||
{isEnabled ? t("camera.disable") : t("camera.enable")}
|
||||
{isEnabled ? t("camera.turnOff") : t("camera.turnOn")}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, ReactNode, useCallback } from "react";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { REVIEW_PADDING } from "@/types/review";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { toast } from "sonner";
|
||||
@@ -94,8 +95,8 @@ export default function SearchResultActions({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: event.camera,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
@@ -129,9 +130,15 @@ export default function SearchResultActions({
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -205,7 +212,7 @@ export default function SearchResultActions({
|
||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{searchResult.has_clip && (
|
||||
{isAdmin && searchResult.has_clip && (
|
||||
<MenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaFilm } from "react-icons/fa6";
|
||||
|
||||
type ActionsDropdownProps = {
|
||||
onDebugReplayClick: () => void;
|
||||
onDebugReplayClick?: () => void;
|
||||
onExportClick: () => void;
|
||||
onShareTimestampClick: () => void;
|
||||
};
|
||||
@@ -42,9 +42,11 @@ export default function ActionsDropdown({
|
||||
<DropdownMenuItem onClick={onShareTimestampClick}>
|
||||
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDebugReplayClick}>
|
||||
{t("title", { ns: "views/replay" })}
|
||||
</DropdownMenuItem>
|
||||
{onDebugReplayClick && (
|
||||
<DropdownMenuItem onClick={onDebugReplayClick}>
|
||||
{t("title", { ns: "views/replay" })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSettings } from "react-icons/lu";
|
||||
import useSWR from "swr";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
|
||||
import { useConfigSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
type DebugReplayConfigSheetProps = {
|
||||
replayCamera: string | undefined;
|
||||
};
|
||||
|
||||
export function DebugReplayConfigSheet({
|
||||
replayCamera,
|
||||
}: DebugReplayConfigSheetProps) {
|
||||
const { t } = useTranslation(["views/replay"]);
|
||||
const configSchema = useConfigSchema();
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<PlatformAwareSheet
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-2">
|
||||
<LuSettings className="size-4" />
|
||||
<span className="hidden md:inline">{t("page.configuration")}</span>
|
||||
</Button>
|
||||
}
|
||||
title={t("page.configuration")}
|
||||
titleClassName="text-lg font-semibold"
|
||||
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
|
||||
content={
|
||||
<>
|
||||
<p className="mb-5 text-sm text-muted-foreground">
|
||||
{t("page.configurationDesc")}
|
||||
</p>
|
||||
{configSchema == null ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="detect"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="motion"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="objects"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
{config?.face_recognition?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="face_recognition"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
{config?.lpr?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="lpr"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { StartExportResponse } from "@/types/export";
|
||||
import { ShareTimestampContent } from "./ShareTimestampDialog";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
type DrawerMode =
|
||||
| "none"
|
||||
@@ -109,6 +110,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
"views/replay",
|
||||
"common",
|
||||
]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const navigate = useNavigate();
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||
const [exportTab, setExportTab] = useState<ExportTab>("export");
|
||||
@@ -388,7 +390,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
{t("filter")}
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("debug-replay") && (
|
||||
{isAdmin && features.includes("debug-replay") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("title", { ns: "views/replay" })}
|
||||
|
||||
@@ -63,8 +63,8 @@ export default function DetailActionsMenu({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: search.camera,
|
||||
start_time: search.start_time,
|
||||
end_time: search.end_time,
|
||||
start_time: (search.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
@@ -95,9 +95,15 @@ export default function DetailActionsMenu({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -229,7 +235,7 @@ export default function DetailActionsMenu({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{search.has_clip && (
|
||||
{isAdmin && search.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
|
||||
@@ -488,7 +488,7 @@ export default function LivePlayer({
|
||||
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
|
||||
<LuVideoOff className="mb-2 size-8 md:size-10" />
|
||||
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
|
||||
{t("cameraDisabled")}
|
||||
{t("cameraOff")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,755 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { LuTrash2, LuPlus } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { processCameraName } from "@/utils/cameraUtil";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfigSetBody } from "@/types/cameraWizard";
|
||||
import { Toaster } from "../ui/sonner";
|
||||
|
||||
const RoleEnum = z.enum(["audio", "detect", "record"]);
|
||||
type Role = z.infer<typeof RoleEnum>;
|
||||
|
||||
type CameraEditFormProps = {
|
||||
cameraName?: string;
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export default function CameraEditForm({
|
||||
cameraName,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: CameraEditFormProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config, mutate: mutateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{
|
||||
cameras: Record<
|
||||
string,
|
||||
{ ffmpeg: { inputs: { path: string; roles: string[] }[] } }
|
||||
>;
|
||||
go2rtc: { streams: Record<string, string | string[]> };
|
||||
}>(cameraName ? "config/raw_paths" : null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
cameraName: z
|
||||
.string()
|
||||
.min(1, { message: t("cameraManagement.cameraConfig.nameRequired") }),
|
||||
enabled: z.boolean(),
|
||||
ffmpeg: z.object({
|
||||
inputs: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string().min(1, {
|
||||
message: t(
|
||||
"cameraManagement.cameraConfig.ffmpeg.pathRequired",
|
||||
),
|
||||
}),
|
||||
roles: z.array(RoleEnum).min(1, {
|
||||
message: t(
|
||||
"cameraManagement.cameraConfig.ffmpeg.rolesRequired",
|
||||
),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.min(1, {
|
||||
message: t("cameraManagement.cameraConfig.ffmpeg.inputsRequired"),
|
||||
})
|
||||
.refine(
|
||||
(inputs) => {
|
||||
const roleOccurrences = new Map<Role, number>();
|
||||
inputs.forEach((input) => {
|
||||
input.roles.forEach((role) => {
|
||||
roleOccurrences.set(
|
||||
role,
|
||||
(roleOccurrences.get(role) || 0) + 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
return Array.from(roleOccurrences.values()).every(
|
||||
(count) => count <= 1,
|
||||
);
|
||||
},
|
||||
{
|
||||
message: t("cameraManagement.cameraConfig.ffmpeg.rolesUnique"),
|
||||
path: ["inputs"],
|
||||
},
|
||||
),
|
||||
}),
|
||||
go2rtcStreams: z.record(z.string(), z.array(z.string())).optional(),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const cameraInfo = useMemo(() => {
|
||||
if (!cameraName || !config?.cameras[cameraName]) {
|
||||
return {
|
||||
friendly_name: undefined,
|
||||
name: cameraName || "",
|
||||
roles: new Set<Role>(),
|
||||
go2rtcStreams: {},
|
||||
};
|
||||
}
|
||||
|
||||
const camera = config.cameras[cameraName];
|
||||
const roles = new Set<Role>();
|
||||
|
||||
camera.ffmpeg?.inputs?.forEach((input) => {
|
||||
input.roles.forEach((role) => roles.add(role as Role));
|
||||
});
|
||||
|
||||
// Load existing go2rtc streams
|
||||
const go2rtcStreams = config.go2rtc?.streams || {};
|
||||
|
||||
return {
|
||||
friendly_name: camera?.friendly_name || cameraName,
|
||||
name: cameraName,
|
||||
roles,
|
||||
go2rtcStreams,
|
||||
};
|
||||
}, [cameraName, config]);
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
cameraName: cameraInfo?.friendly_name || cameraName || "",
|
||||
enabled: true,
|
||||
ffmpeg: {
|
||||
inputs: [
|
||||
{
|
||||
path: "",
|
||||
roles: cameraInfo.roles.has("detect") ? [] : ["detect"],
|
||||
},
|
||||
],
|
||||
},
|
||||
go2rtcStreams: {},
|
||||
};
|
||||
|
||||
// Load existing camera config if editing
|
||||
if (cameraName && config?.cameras[cameraName]) {
|
||||
const camera = config.cameras[cameraName];
|
||||
defaultValues.enabled = camera.enabled ?? true;
|
||||
|
||||
// Use raw paths from the admin endpoint if available, otherwise fall back to masked paths
|
||||
const rawCameraData = rawPaths?.cameras?.[cameraName];
|
||||
defaultValues.ffmpeg.inputs = rawCameraData?.ffmpeg?.inputs?.length
|
||||
? rawCameraData.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
}))
|
||||
: camera.ffmpeg?.inputs?.length
|
||||
? camera.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
}))
|
||||
: defaultValues.ffmpeg.inputs;
|
||||
|
||||
const go2rtcStreams =
|
||||
rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {};
|
||||
const cameraStreams: Record<string, string[]> = {};
|
||||
|
||||
// get candidate stream names for this camera. could be the camera's own name,
|
||||
// any restream names referenced by this camera, or any keys under live --> streams
|
||||
const validNames = new Set<string>();
|
||||
validNames.add(cameraName);
|
||||
|
||||
// deduce go2rtc stream names from rtsp restream inputs
|
||||
camera.ffmpeg?.inputs?.forEach((input) => {
|
||||
// exclude any query strings or trailing slashes from the stream name
|
||||
const restreamMatch = input.path.match(
|
||||
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
|
||||
);
|
||||
if (restreamMatch) {
|
||||
const streamName = restreamMatch[1];
|
||||
validNames.add(streamName);
|
||||
}
|
||||
});
|
||||
|
||||
// Include live --> streams keys
|
||||
const liveStreams = camera?.live?.streams;
|
||||
if (liveStreams) {
|
||||
Object.keys(liveStreams).forEach((key) => {
|
||||
validNames.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Map only go2rtc entries that match the collected names
|
||||
Object.entries(go2rtcStreams).forEach(([name, urls]) => {
|
||||
if (validNames.has(name)) {
|
||||
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
|
||||
}
|
||||
});
|
||||
|
||||
defaultValues.go2rtcStreams = cameraStreams;
|
||||
}
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
// Update form values when rawPaths loads
|
||||
useEffect(() => {
|
||||
if (
|
||||
cameraName &&
|
||||
config?.cameras[cameraName] &&
|
||||
rawPaths?.cameras?.[cameraName]
|
||||
) {
|
||||
const camera = config.cameras[cameraName];
|
||||
const rawCameraData = rawPaths.cameras[cameraName];
|
||||
|
||||
// Update ffmpeg inputs with raw paths
|
||||
if (rawCameraData.ffmpeg?.inputs?.length) {
|
||||
form.setValue(
|
||||
"ffmpeg.inputs",
|
||||
rawCameraData.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Update go2rtc streams with raw URLs
|
||||
if (rawPaths.go2rtc?.streams) {
|
||||
const validNames = new Set<string>();
|
||||
validNames.add(cameraName);
|
||||
|
||||
camera.ffmpeg?.inputs?.forEach((input) => {
|
||||
const restreamMatch = input.path.match(
|
||||
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
|
||||
);
|
||||
if (restreamMatch) {
|
||||
validNames.add(restreamMatch[1]);
|
||||
}
|
||||
});
|
||||
|
||||
const liveStreams = camera?.live?.streams;
|
||||
if (liveStreams) {
|
||||
Object.keys(liveStreams).forEach((key) => validNames.add(key));
|
||||
}
|
||||
|
||||
const cameraStreams: Record<string, string[]> = {};
|
||||
Object.entries(rawPaths.go2rtc.streams).forEach(([name, urls]) => {
|
||||
if (validNames.has(name)) {
|
||||
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(cameraStreams).length > 0) {
|
||||
form.setValue("go2rtcStreams", cameraStreams);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [cameraName, config, rawPaths, form]);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ffmpeg.inputs",
|
||||
});
|
||||
|
||||
// Watch ffmpeg.inputs to track used roles
|
||||
const watchedInputs = form.watch("ffmpeg.inputs");
|
||||
|
||||
// Watch go2rtc streams
|
||||
const watchedGo2rtcStreams = form.watch("go2rtcStreams") || {};
|
||||
|
||||
const saveCameraConfig = (values: FormValues) => {
|
||||
setIsLoading(true);
|
||||
const { finalCameraName, friendlyName } = processCameraName(
|
||||
values.cameraName,
|
||||
);
|
||||
|
||||
const configData: ConfigSetBody["config_data"] = {
|
||||
cameras: {
|
||||
[finalCameraName]: {
|
||||
enabled: values.enabled,
|
||||
...(friendlyName && { friendly_name: friendlyName }),
|
||||
ffmpeg: {
|
||||
inputs: values.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add go2rtc streams if provided
|
||||
if (values.go2rtcStreams && Object.keys(values.go2rtcStreams).length > 0) {
|
||||
configData.go2rtc = {
|
||||
streams: values.go2rtcStreams,
|
||||
};
|
||||
}
|
||||
|
||||
const requestBody: ConfigSetBody = {
|
||||
requires_restart: 1,
|
||||
config_data: configData,
|
||||
};
|
||||
|
||||
// Add update_topic for new cameras
|
||||
if (!cameraName) {
|
||||
requestBody.update_topic = `config/cameras/${finalCameraName}/add`;
|
||||
}
|
||||
|
||||
axios
|
||||
.put("config/set", requestBody)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
// Update running go2rtc instance if streams were configured
|
||||
if (
|
||||
values.go2rtcStreams &&
|
||||
Object.keys(values.go2rtcStreams).length > 0
|
||||
) {
|
||||
const updatePromises = Object.entries(values.go2rtcStreams).map(
|
||||
([streamName, urls]) =>
|
||||
axios.put(
|
||||
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
|
||||
),
|
||||
);
|
||||
|
||||
Promise.allSettled(updatePromises).then(() => {
|
||||
toast.success(
|
||||
t("cameraManagement.cameraConfig.toast.success", {
|
||||
cameraName: values.cameraName,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
mutateConfig();
|
||||
mutateRawPaths();
|
||||
if (onSave) onSave();
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
t("cameraManagement.cameraConfig.toast.success", {
|
||||
cameraName: values.cameraName,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
mutateConfig();
|
||||
mutateRawPaths();
|
||||
if (onSave) onSave();
|
||||
}
|
||||
} else {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (
|
||||
cameraName &&
|
||||
values.cameraName !== cameraName &&
|
||||
values.cameraName !== cameraInfo?.friendly_name
|
||||
) {
|
||||
// If camera name changed, delete old camera config
|
||||
const deleteRequestBody = {
|
||||
requires_restart: 1,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[cameraName]: null,
|
||||
},
|
||||
},
|
||||
update_topic: `config/cameras/${cameraName}/remove`,
|
||||
};
|
||||
|
||||
axios
|
||||
.put("config/set", deleteRequestBody)
|
||||
.then(() => saveCameraConfig(values))
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
saveCameraConfig(values);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine available roles for new streams
|
||||
const getAvailableRoles = (): Role[] => {
|
||||
const used = new Set<Role>();
|
||||
watchedInputs.forEach((input) => {
|
||||
input.roles.forEach((role) => used.add(role));
|
||||
});
|
||||
return used.has("detect") ? [] : ["detect"];
|
||||
};
|
||||
|
||||
const getUsedRolesExcludingIndex = (excludeIndex: number) => {
|
||||
const roles = new Set<Role>();
|
||||
watchedInputs.forEach((input, idx) => {
|
||||
if (idx !== excludeIndex) {
|
||||
input.roles.forEach((role) => roles.add(role));
|
||||
}
|
||||
});
|
||||
return roles;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scrollbar-container max-w-4xl overflow-y-auto md:mb-24">
|
||||
<Toaster position="top-center" closeButton />
|
||||
<Heading as="h3" className="my-2">
|
||||
{cameraName
|
||||
? t("cameraManagement.cameraConfig.edit")
|
||||
: t("cameraManagement.cameraConfig.add")}
|
||||
</Heading>
|
||||
<div className="my-3 text-sm text-muted-foreground">
|
||||
{t("cameraManagement.cameraConfig.description")}
|
||||
</div>
|
||||
<Separator className="my-3 bg-secondary" />
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cameraName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("cameraManagement.cameraConfig.name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"cameraManagement.cameraConfig.namePlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
disabled={!!cameraName} // Prevent editing name for existing cameras
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>
|
||||
{t("cameraManagement.cameraConfig.enabled")}
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("cameraManagement.cameraConfig.ffmpeg.inputs")}
|
||||
</Label>
|
||||
{fields.map((field, index) => (
|
||||
<Card key={field.id} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step3.streamTitle", {
|
||||
number: index + 1,
|
||||
})}
|
||||
</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
disabled={fields.length === 1}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ffmpeg.inputs.${index}.path`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{t("cameraManagement.cameraConfig.ffmpeg.path")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
placeholder={t(
|
||||
"cameraManagement.cameraConfig.ffmpeg.pathPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("cameraManagement.cameraConfig.ffmpeg.roles")}
|
||||
</Label>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["detect", "record", "audio"] as const).map(
|
||||
(role) => {
|
||||
const isUsedElsewhere =
|
||||
getUsedRolesExcludingIndex(index).has(role);
|
||||
const isChecked =
|
||||
watchedInputs[index]?.roles?.includes(role) ||
|
||||
false;
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="text-sm capitalize">
|
||||
{role}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentRoles =
|
||||
watchedInputs[index]?.roles || [];
|
||||
const updatedRoles = checked
|
||||
? [...currentRoles, role]
|
||||
: currentRoles.filter((r) => r !== role);
|
||||
form.setValue(
|
||||
`ffmpeg.inputs.${index}.roles`,
|
||||
updatedRoles,
|
||||
);
|
||||
}}
|
||||
disabled={!isChecked && isUsedElsewhere}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<FormMessage>
|
||||
{form.formState.errors.ffmpeg?.inputs?.root &&
|
||||
form.formState.errors.ffmpeg.inputs.root.message}
|
||||
</FormMessage>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => append({ path: "", roles: getAvailableRoles() })}
|
||||
variant="outline"
|
||||
className=""
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraManagement.cameraConfig.ffmpeg.addInput")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* go2rtc Streams Section */}
|
||||
{Object.keys(watchedGo2rtcStreams).length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("cameraManagement.cameraConfig.go2rtcStreams")}
|
||||
</Label>
|
||||
{Object.entries(watchedGo2rtcStreams).map(
|
||||
([streamName, urls]) => (
|
||||
<Card key={streamName} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{streamName}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const updatedStreams = { ...watchedGo2rtcStreams };
|
||||
delete updatedStreams[streamName];
|
||||
form.setValue("go2rtcStreams", updatedStreams);
|
||||
}}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("cameraManagement.cameraConfig.streamUrls")}
|
||||
</Label>
|
||||
{(Array.isArray(urls) ? urls : [urls]).map(
|
||||
(url, urlIndex) => (
|
||||
<div
|
||||
key={urlIndex}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
className="h-8 flex-1"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
const updatedStreams = {
|
||||
...watchedGo2rtcStreams,
|
||||
};
|
||||
const currentUrls = Array.isArray(
|
||||
updatedStreams[streamName],
|
||||
)
|
||||
? updatedStreams[streamName]
|
||||
: [updatedStreams[streamName]];
|
||||
currentUrls[urlIndex] = e.target.value;
|
||||
updatedStreams[streamName] = currentUrls;
|
||||
form.setValue(
|
||||
"go2rtcStreams",
|
||||
updatedStreams,
|
||||
);
|
||||
}}
|
||||
placeholder="rtsp://username:password@host:port/path"
|
||||
/>
|
||||
{(Array.isArray(urls) ? urls : [urls]).length >
|
||||
1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const updatedStreams = {
|
||||
...watchedGo2rtcStreams,
|
||||
};
|
||||
const currentUrls = Array.isArray(
|
||||
updatedStreams[streamName],
|
||||
)
|
||||
? updatedStreams[streamName]
|
||||
: [updatedStreams[streamName]];
|
||||
currentUrls.splice(urlIndex, 1);
|
||||
updatedStreams[streamName] = currentUrls;
|
||||
form.setValue(
|
||||
"go2rtcStreams",
|
||||
updatedStreams,
|
||||
);
|
||||
}}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const updatedStreams = { ...watchedGo2rtcStreams };
|
||||
const currentUrls = Array.isArray(
|
||||
updatedStreams[streamName],
|
||||
)
|
||||
? updatedStreams[streamName]
|
||||
: [updatedStreams[streamName]];
|
||||
currentUrls.push("");
|
||||
updatedStreams[streamName] = currentUrls;
|
||||
form.setValue("go2rtcStreams", updatedStreams);
|
||||
}}
|
||||
className="w-fit"
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraManagement.cameraConfig.addUrl")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const streamName = `${cameraName}_stream_${Object.keys(watchedGo2rtcStreams).length + 1}`;
|
||||
const updatedStreams = {
|
||||
...watchedGo2rtcStreams,
|
||||
[streamName]: [""],
|
||||
};
|
||||
form.setValue("go2rtcStreams", updatedStreams);
|
||||
}}
|
||||
variant="outline"
|
||||
className=""
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraManagement.cameraConfig.addGo2rtcStream")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -258,8 +258,9 @@ export default function MotionMaskEditPane({
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
// Only publish WS state for base config when mask has a name
|
||||
if (!editingProfile && maskName) {
|
||||
// Only publish WS state for base config when mask has a name and
|
||||
// wasn't renamed (the hook is bound to the old name).
|
||||
if (!editingProfile && maskName && !renamingMask) {
|
||||
sendMotionMaskState(enabled ? "ON" : "OFF");
|
||||
}
|
||||
} else {
|
||||
@@ -414,6 +415,7 @@ export default function MotionMaskEditPane({
|
||||
nameLabel={t("masksAndZones.motionMasks.name.title")}
|
||||
nameDescription={t("masksAndZones.motionMasks.name.description")}
|
||||
placeholderName={t("masksAndZones.motionMasks.name.placeholder")}
|
||||
idDisabled={!!editingProfile && polygon.name.length > 0}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -263,8 +263,9 @@ export default function ObjectMaskEditPane({
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
// Only publish WS state for base config when mask has a name
|
||||
if (!editingProfile && maskName) {
|
||||
// Only publish WS state for base config when mask has a name and
|
||||
// wasn't renamed (the hook is bound to the old name).
|
||||
if (!editingProfile && maskName && !renamingMask) {
|
||||
sendObjectMaskState(enabled ? "ON" : "OFF");
|
||||
}
|
||||
} else {
|
||||
@@ -389,6 +390,7 @@ export default function ObjectMaskEditPane({
|
||||
placeholderName={t(
|
||||
"masksAndZones.objectMasks.name.placeholder",
|
||||
)}
|
||||
idDisabled={!!editingProfile && polygon.name.length > 0}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -94,6 +94,28 @@ export default function ZoneEditPane({
|
||||
const zoneName = polygon?.name || "";
|
||||
const { send: sendZoneState } = useZoneState(polygon?.camera || "", zoneName);
|
||||
|
||||
const isExistingZone = !!polygon && polygon.name.length > 0;
|
||||
|
||||
const idDisabled = useMemo(() => {
|
||||
if (!isExistingZone || !polygon) {
|
||||
return false;
|
||||
}
|
||||
if (editingProfile) {
|
||||
return true;
|
||||
}
|
||||
const cam = config?.cameras[polygon.camera];
|
||||
if (!cam) {
|
||||
return false;
|
||||
}
|
||||
const inRequiredZones =
|
||||
cam.review.alerts.required_zones.includes(polygon.name) ||
|
||||
cam.review.detections.required_zones.includes(polygon.name);
|
||||
const hasProfileOverride = Object.values(cam.profiles ?? {}).some(
|
||||
(profile) => profile?.zones && polygon.name in profile.zones,
|
||||
);
|
||||
return inRequiredZones || hasProfileOverride;
|
||||
}, [config, polygon, editingProfile, isExistingZone]);
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (polygon?.camera && config) {
|
||||
return config.cameras[polygon.camera];
|
||||
@@ -419,6 +441,7 @@ export default function ZoneEditPane({
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -444,6 +467,7 @@ export default function ZoneEditPane({
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -527,8 +551,9 @@ export default function ZoneEditPane({
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
// Only publish WS state for base config when zone has a name
|
||||
if (!editingProfile && polygon?.name) {
|
||||
// Only publish WS state for base config when zone has a name and
|
||||
// wasn't renamed (the hook is bound to the old name).
|
||||
if (!editingProfile && polygon?.name && !renamingZone) {
|
||||
sendZoneState(enabled ? "ON" : "OFF");
|
||||
}
|
||||
} else {
|
||||
@@ -650,6 +675,7 @@ export default function ZoneEditPane({
|
||||
nameLabel={t("masksAndZones.zones.name.title")}
|
||||
nameDescription={t("masksAndZones.zones.name.tips")}
|
||||
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||
idDisabled={idDisabled}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Event } from "@/types/event";
|
||||
import { REVIEW_PADDING } from "@/types/review";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
@@ -58,8 +59,8 @@ export default function EventMenu({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: event.camera,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
@@ -93,9 +94,15 @@ export default function EventMenu({
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -176,7 +183,7 @@ export default function EventMenu({
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{event.has_clip && (
|
||||
{isAdmin && event.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
disabled={isStarting}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
|
||||
import { DebugReplayConfigSheet } from "@/components/overlay/DebugReplayConfigSheet";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@@ -40,16 +40,14 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { ObjectType } from "@/types/ws";
|
||||
import { useJobStatus } from "@/api/ws";
|
||||
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
|
||||
|
||||
import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu";
|
||||
import { LuExternalLink, LuInfo } from "react-icons/lu";
|
||||
import { LuSquare } from "react-icons/lu";
|
||||
import { MdReplay } from "react-icons/md";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import Logo from "@/components/Logo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { useConfigSchema } from "@/hooks/use-config-schema";
|
||||
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
|
||||
@@ -125,7 +123,6 @@ export default function Replay() {
|
||||
});
|
||||
const { payload: replayJob } =
|
||||
useJobStatus<DebugReplayJobResults>("debug_replay");
|
||||
const configSchema = useConfigSchema();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
|
||||
// Refresh status immediately on mount to avoid showing "no session" briefly
|
||||
@@ -139,7 +136,6 @@ export default function Replay() {
|
||||
|
||||
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
|
||||
const searchParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -327,91 +323,8 @@ export default function Replay() {
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<PlatformAwareSheet
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuSettings className="size-4" />
|
||||
<span className="hidden md:inline">
|
||||
{t("page.configuration")}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
title={t("page.configuration")}
|
||||
titleClassName="text-lg font-semibold"
|
||||
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
|
||||
content={
|
||||
<>
|
||||
<p className="mb-5 text-sm text-muted-foreground">
|
||||
{t("page.configurationDesc")}
|
||||
</p>
|
||||
{configSchema == null ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="motion"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="objects"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
{config?.face_recognition?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="face_recognition"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
{config?.lpr?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="lpr"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
<DebugReplayConfigSheet
|
||||
replayCamera={status.replay_camera ?? undefined}
|
||||
/>
|
||||
|
||||
<AlertDialog>
|
||||
|
||||
+181
-69
@@ -16,6 +16,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -305,6 +310,8 @@ const settingsGroups = [
|
||||
{
|
||||
label: "globalConfig",
|
||||
items: [
|
||||
{ key: "profiles", component: ProfilesView },
|
||||
{ key: "cameraManagement", component: CameraManagementView },
|
||||
{ key: "globalDetect", component: GlobalDetectSettingsPage },
|
||||
{ key: "globalObjects", component: GlobalObjectsSettingsPage },
|
||||
{ key: "globalMotion", component: GlobalMotionSettingsPage },
|
||||
@@ -326,8 +333,6 @@ const settingsGroups = [
|
||||
{
|
||||
label: "cameras",
|
||||
items: [
|
||||
{ key: "profiles", component: ProfilesView },
|
||||
{ key: "cameraManagement", component: CameraManagementView },
|
||||
{ key: "cameraDetect", component: CameraDetectSettingsPage },
|
||||
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
|
||||
{ key: "cameraMotion", component: CameraMotionSettingsPage },
|
||||
@@ -589,7 +594,7 @@ function MobileMenuItem({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex h-10 w-full cursor-pointer items-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -600,7 +605,6 @@ function MobileMenuItem({
|
||||
<div className="w-full">
|
||||
{label ?? <div>{t("menu." + item.key)}</div>}
|
||||
</div>
|
||||
<LuChevronRight className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -613,6 +617,39 @@ export default function Settings() {
|
||||
const [sectionStatusByKey, setSectionStatusByKey] = useState<
|
||||
Partial<Record<SettingsType, SectionStatus>>
|
||||
>({});
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
|
||||
() =>
|
||||
// all collapsed by default
|
||||
new Set(
|
||||
settingsGroups.filter((g) => g.items.length > 1).map((g) => g.label),
|
||||
),
|
||||
);
|
||||
|
||||
const toggleGroupCollapsed = useCallback((label: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) {
|
||||
next.delete(label);
|
||||
} else {
|
||||
next.add(label);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-expand the group containing the active page whenever pageToggle changes
|
||||
useEffect(() => {
|
||||
const containingGroup = settingsGroups.find((group) =>
|
||||
group.items.some((item) => item.key === pageToggle),
|
||||
);
|
||||
if (!containingGroup) return;
|
||||
setCollapsedGroups((prev) => {
|
||||
if (!prev.has(containingGroup.label)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(containingGroup.label);
|
||||
return next;
|
||||
});
|
||||
}, [pageToggle]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
@@ -1611,34 +1648,58 @@ export default function Settings() {
|
||||
visibleSettingsViews.includes(item.key as SettingsType),
|
||||
);
|
||||
if (filteredItems.length === 0) return null;
|
||||
const isMultiItem = filteredItems.length > 1;
|
||||
const renderedExpanded =
|
||||
!isMultiItem || !collapsedGroups.has(group.label);
|
||||
const items = filteredItems.map((item) => (
|
||||
<MobileMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
className={cn(filteredItems.length == 1 && "pl-2")}
|
||||
label={renderMenuItemLabel(item.key as SettingsType)}
|
||||
onSelect={(key) => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(key as SettingsType)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(key as SettingsType);
|
||||
}
|
||||
setContentMobileOpen(true);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<div key={group.label} className="mb-3">
|
||||
{filteredItems.length > 1 && (
|
||||
<h3 className="mb-2 ml-2 text-sm font-medium text-secondary-foreground">
|
||||
<div>{t("menu." + group.label)}</div>
|
||||
</h3>
|
||||
{isMultiItem ? (
|
||||
<Collapsible
|
||||
open={renderedExpanded}
|
||||
onOpenChange={() => toggleGroupCollapsed(group.label)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex min-h-10 w-full items-center justify-between rounded-md py-2 pl-2 pr-2 text-sm font-medium text-secondary-foreground">
|
||||
<div className="flex flex-col justify-start gap-0.5 text-left">
|
||||
{t("menu." + group.label)}
|
||||
{group.label === "cameras" &&
|
||||
renderedExpanded &&
|
||||
selectedCamera && (
|
||||
<div className="max-w-full break-words text-xs text-secondary-foreground/80 smart-capitalize">
|
||||
<CameraNameLabel camera={selectedCamera} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LuChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200",
|
||||
renderedExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{items}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
items
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<MobileMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
className={cn(filteredItems.length == 1 && "pl-2")}
|
||||
label={renderMenuItemLabel(item.key as SettingsType)}
|
||||
onSelect={(key) => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||
key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(key as SettingsType);
|
||||
}
|
||||
setContentMobileOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1940,48 +2001,99 @@ export default function Settings() {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
) : (
|
||||
<>
|
||||
<SidebarGroupLabel
|
||||
className={cn(
|
||||
"ml-2 cursor-default pl-0 text-sm",
|
||||
filteredItems.some(
|
||||
(item) => pageToggle === item.key,
|
||||
)
|
||||
? "text-primary"
|
||||
: "text-sidebar-foreground/80",
|
||||
)}
|
||||
>
|
||||
<div>{t("menu." + group.label)}</div>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenuSub className="mx-2 border-0">
|
||||
{filteredItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.key}>
|
||||
<SidebarMenuSubButton
|
||||
className="h-auto w-full py-1.5"
|
||||
isActive={pageToggle === item.key}
|
||||
onClick={() => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||
item.key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(item.key as SettingsType);
|
||||
}
|
||||
}}
|
||||
(() => {
|
||||
const hasActiveItem = filteredItems.some(
|
||||
(item) => pageToggle === item.key,
|
||||
);
|
||||
const renderedExpanded = !collapsedGroups.has(
|
||||
group.label,
|
||||
);
|
||||
return (
|
||||
<Collapsible
|
||||
open={renderedExpanded}
|
||||
onOpenChange={() =>
|
||||
toggleGroupCollapsed(group.label)
|
||||
}
|
||||
>
|
||||
<SidebarGroupLabel
|
||||
asChild
|
||||
className={cn(
|
||||
"ml-2 pl-0 text-sm",
|
||||
hasActiveItem
|
||||
? "text-primary"
|
||||
: "text-sidebar-foreground/80",
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between",
|
||||
renderedExpanded &&
|
||||
group.label == "cameras" &&
|
||||
"mb-2",
|
||||
)}
|
||||
>
|
||||
<div className="w-full cursor-pointer">
|
||||
{renderMenuItemLabel(
|
||||
item.key as SettingsType,
|
||||
)}
|
||||
<div className="flex flex-col justify-start gap-0.5 text-left">
|
||||
{t("menu." + group.label)}
|
||||
{group.label === "cameras" &&
|
||||
renderedExpanded &&
|
||||
selectedCamera && (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full break-words text-xs smart-capitalize",
|
||||
hasActiveItem
|
||||
? "text-primary/60"
|
||||
: "text-sidebar-foreground/80",
|
||||
)}
|
||||
>
|
||||
<CameraNameLabel
|
||||
camera={selectedCamera}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</>
|
||||
<LuChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200",
|
||||
renderedExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub className="mx-2 border-0 md:mx-0">
|
||||
{filteredItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.key}>
|
||||
<SidebarMenuSubButton
|
||||
className="h-auto w-full py-1.5"
|
||||
isActive={pageToggle === item.key}
|
||||
onClick={() => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||
item.key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(
|
||||
item.key as SettingsType,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full cursor-pointer">
|
||||
{renderMenuItemLabel(
|
||||
item.key as SettingsType,
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
@@ -229,7 +229,12 @@ export function buildOverrides(
|
||||
|
||||
const result: JsonObject = {};
|
||||
for (const [key, value] of Object.entries(currentObj)) {
|
||||
if (value === undefined && baseObj && baseObj[key] !== undefined) {
|
||||
if (
|
||||
(value === undefined || value === null) &&
|
||||
baseObj &&
|
||||
baseObj[key] !== undefined &&
|
||||
baseObj[key] !== null
|
||||
) {
|
||||
result[key] = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import VideoControls from "@/components/player/VideoControls";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import {
|
||||
useCameraMotionNextTimestamp,
|
||||
useCameraMotionOnlyRanges,
|
||||
@@ -1008,27 +1009,29 @@ function MotionReview({
|
||||
const { t } = useTranslation(["views/events", "common"]);
|
||||
const segmentDuration = 30;
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const allowedCameras = useAllowedCameras();
|
||||
|
||||
const reviewCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let cameras;
|
||||
if (!filter || !filter.cameras) {
|
||||
cameras = Object.values(config.cameras).filter(
|
||||
(cam) => !isReplayCamera(cam.name),
|
||||
);
|
||||
} else {
|
||||
const filteredCams = filter.cameras;
|
||||
|
||||
cameras = Object.values(config.cameras).filter(
|
||||
(cam) => filteredCams.includes(cam.name) && !isReplayCamera(cam.name),
|
||||
);
|
||||
}
|
||||
const selectedCams = filter?.cameras;
|
||||
const cameras = Object.values(config.cameras).filter((cam) => {
|
||||
if (isReplayCamera(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
if (!allowedCameras.includes(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedCams && !selectedCams.includes(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
||||
}, [config, filter]);
|
||||
}, [config, filter, allowedCameras]);
|
||||
|
||||
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
|
||||
|
||||
|
||||
@@ -1048,7 +1048,7 @@ function FrigateCameraFeatures({
|
||||
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
|
||||
isActive={enabledState == "ON"}
|
||||
title={
|
||||
enabledState == "ON" ? t("camera.disable") : t("camera.enable")
|
||||
enabledState == "ON" ? t("camera.turnOff") : t("camera.turnOn")
|
||||
}
|
||||
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
|
||||
disabled={debug}
|
||||
@@ -1489,7 +1489,7 @@ function FrigateCameraFeatures({
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.cameraEnabled")}
|
||||
label={t("cameraSettings.camera")}
|
||||
isChecked={enabledState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
@@ -90,6 +91,7 @@ export default function LiveDashboardView({
|
||||
// recent events
|
||||
|
||||
const eventUpdate = useFrigateReviews();
|
||||
const allowedCameras = useAllowedCameras();
|
||||
|
||||
const alertCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
@@ -98,14 +100,16 @@ export default function LiveDashboardView({
|
||||
|
||||
if (cameraGroup == "default") {
|
||||
return Object.values(config.cameras)
|
||||
.filter((cam) => cam.ui.dashboard)
|
||||
.filter((cam) => cam.ui.dashboard && allowedCameras.includes(cam.name))
|
||||
.map((cam) => cam.name)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
if (includeBirdseye && cameras.length == 0) {
|
||||
return Object.values(config.cameras)
|
||||
.filter((cam) => cam.birdseye.enabled)
|
||||
.filter(
|
||||
(cam) => cam.birdseye.enabled && allowedCameras.includes(cam.name),
|
||||
)
|
||||
.map((cam) => cam.name)
|
||||
.join(",");
|
||||
}
|
||||
@@ -114,7 +118,7 @@ export default function LiveDashboardView({
|
||||
.map((cam) => cam.name)
|
||||
.filter((cam) => config.camera_groups[cameraGroup]?.cameras.includes(cam))
|
||||
.join(",");
|
||||
}, [cameras, cameraGroup, config, includeBirdseye]);
|
||||
}, [cameras, cameraGroup, config, includeBirdseye, allowedCameras]);
|
||||
|
||||
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
|
||||
"review",
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import useSWR from "swr";
|
||||
import { TimeRange, TimelineType } from "@/types/timeline";
|
||||
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
||||
@@ -109,6 +110,7 @@ export function RecordingView({
|
||||
}: RecordingViewProps) {
|
||||
const { t } = useTranslation(["views/events", "components/dialog"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const isAdmin = useIsAdmin();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -723,13 +725,17 @@ export function RecordingView({
|
||||
setCustomShareTimestamp(initialTimestamp);
|
||||
setShareTimestampOpen(true);
|
||||
}}
|
||||
onDebugReplayClick={() => {
|
||||
setDebugReplayRange({
|
||||
after: timeRange.before - 60,
|
||||
before: timeRange.before,
|
||||
});
|
||||
setDebugReplayMode("select");
|
||||
}}
|
||||
onDebugReplayClick={
|
||||
isAdmin
|
||||
? () => {
|
||||
setDebugReplayRange({
|
||||
after: timeRange.before - 60,
|
||||
before: timeRange.before,
|
||||
});
|
||||
setDebugReplayMode("select");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onExportClick={() => {
|
||||
const now = new Date(timeRange.before * 1000);
|
||||
now.setHours(now.getHours() - 1);
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Button } from "@/components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
||||
import {
|
||||
@@ -20,15 +19,13 @@ import {
|
||||
LuGripVertical,
|
||||
LuPencil,
|
||||
LuPlus,
|
||||
LuRefreshCcw,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trans } from "react-i18next";
|
||||
import { useEnabledState, useRestart } from "@/api/ws";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -78,12 +75,10 @@ const REORDER_SAVED_INDICATOR_MS = 1500;
|
||||
type ReorderSaveStatus = "idle" | "saving" | "saved";
|
||||
|
||||
type CameraManagementViewProps = {
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
profileState?: ProfileState;
|
||||
};
|
||||
|
||||
export default function CameraManagementView({
|
||||
setUnsavedChanges,
|
||||
profileState,
|
||||
}: CameraManagementViewProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
@@ -91,12 +86,6 @@ export default function CameraManagementView({
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
|
||||
"settings",
|
||||
); // Control view state
|
||||
const [editCameraName, setEditCameraName] = useState<string | undefined>(
|
||||
undefined,
|
||||
); // Track camera being edited
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
@@ -226,14 +215,6 @@ export default function CameraManagementView({
|
||||
document.title = t("documentTitle.cameraManagement");
|
||||
}, [t]);
|
||||
|
||||
// Handle back navigation from add/edit form
|
||||
const handleBack = useCallback(() => {
|
||||
setViewMode("settings");
|
||||
setEditCameraName(undefined);
|
||||
setUnsavedChanges(false);
|
||||
updateConfig();
|
||||
}, [updateConfig, setUnsavedChanges]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster
|
||||
@@ -244,174 +225,124 @@ export default function CameraManagementView({
|
||||
/>
|
||||
<div className="flex size-full space-y-6">
|
||||
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
|
||||
{viewMode === "settings" ? (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("cameraManagement.title")}
|
||||
</Heading>
|
||||
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
|
||||
{t("cameraManagement.description")}
|
||||
</p>
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("cameraManagement.title")}
|
||||
</Heading>
|
||||
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
|
||||
{t("cameraManagement.description")}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-5xl space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="mb-2 flex max-w-48 items-center gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("cameraManagement.addCamera")}
|
||||
</Button>
|
||||
{enabledCameras.length + disabledCameras.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="mb-2 flex max-w-48 items-center gap-2 text-white"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
{t("cameraManagement.deleteCamera")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full max-w-5xl space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="mb-2 flex max-w-48 items-center gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("cameraManagement.addCamera")}
|
||||
</Button>
|
||||
{enabledCameras.length + disabledCameras.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="mb-2 flex max-w-48 items-center gap-2 text-white"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
{t("cameraManagement.deleteCamera")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enabledCameras.length > 0 && (
|
||||
<SettingsGroupCard
|
||||
title={
|
||||
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
|
||||
<SettingsGroupCard
|
||||
title={
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.title
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<div className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("cameraManagement.streams.label")}</Label>
|
||||
<p className="hidden text-sm text-muted-foreground md:block">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.title
|
||||
cameraManagement.streams.description
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<div className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className="cursor-pointer"
|
||||
htmlFor={"enabled-cameras-switch"}
|
||||
>
|
||||
{t("cameraManagement.streams.enableLabel")}
|
||||
<p className="hidden text-sm text-muted-foreground md:block">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.enableDesc
|
||||
</Trans>
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<div className="space-y-3 rounded-lg bg-secondary p-4">
|
||||
{orderedCameras.length > 0 && (
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={orderedCameras}
|
||||
onReorder={setOrderedCameras}
|
||||
className="space-y-2 rounded-lg bg-secondary p-4"
|
||||
className="space-y-2"
|
||||
>
|
||||
{orderedCameras.map((camera) => (
|
||||
<EnabledCameraRow
|
||||
<ActiveCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
onDragEnd={handleReorderDragEnd}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
<ReorderSaveStatusIndicator
|
||||
status={reorderSaveStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground md:hidden">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.enableDesc
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
{disabledCameras.length > 0 && (
|
||||
<div className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className="cursor-pointer"
|
||||
htmlFor={"disabled-cameras-switch"}
|
||||
>
|
||||
{t("cameraManagement.streams.disableLabel")}
|
||||
<RestartRequiredIndicator className="ml-1" />
|
||||
</Label>
|
||||
<p className="hidden text-sm text-muted-foreground md:block">
|
||||
{t("cameraManagement.streams.disableDesc")}
|
||||
)}
|
||||
{orderedCameras.length > 0 &&
|
||||
disabledCameras.length > 0 && (
|
||||
<div className="border-t border-border/40" />
|
||||
)}
|
||||
{disabledCameras.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("cameraManagement.streams.disabledSubheading")}
|
||||
</p>
|
||||
{disabledCameras.map((camera) => (
|
||||
<DisabledCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
|
||||
>
|
||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||
{disabledCameras.map((camera) => (
|
||||
<div
|
||||
key={camera}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraConfigEnableSwitch
|
||||
cameraName={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground md:hidden">
|
||||
{t("cameraManagement.streams.disableDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroupCard>
|
||||
)}
|
||||
|
||||
{profileState &&
|
||||
profileState.allProfileNames.length > 0 &&
|
||||
enabledCameras.length > 0 && (
|
||||
<ProfileCameraEnableSection
|
||||
profileState={profileState}
|
||||
cameras={enabledCameras}
|
||||
config={config}
|
||||
onConfigChanged={updateConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config?.lpr?.enabled && allCameras.length > 0 && (
|
||||
<CameraTypeSection
|
||||
cameras={allCameras}
|
||||
config={config}
|
||||
onConfigChanged={updateConfig}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="md:max-w-5xl">
|
||||
<CameraEditForm
|
||||
cameraName={viewMode === "edit" ? editCameraName : undefined}
|
||||
onSave={handleBack}
|
||||
onCancel={handleBack}
|
||||
<ReorderSaveStatusIndicator status={reorderSaveStatus} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground md:hidden">
|
||||
<Trans ns="views/settings">
|
||||
cameraManagement.streams.description
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroupCard>
|
||||
)}
|
||||
|
||||
{profileState &&
|
||||
profileState.allProfileNames.length > 0 &&
|
||||
enabledCameras.length > 0 && (
|
||||
<ProfileCameraEnableSection
|
||||
profileState={profileState}
|
||||
cameras={enabledCameras}
|
||||
config={config}
|
||||
onConfigChanged={updateConfig}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{config?.lpr?.enabled && allCameras.length > 0 && (
|
||||
<CameraTypeSection
|
||||
cameras={allCameras}
|
||||
config={config}
|
||||
onConfigChanged={updateConfig}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -468,17 +399,19 @@ function ReorderSaveStatusIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
type EnabledCameraRowProps = {
|
||||
type ActiveCameraRowProps = {
|
||||
camera: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
onDragEnd: () => void;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
function EnabledCameraRow({
|
||||
function ActiveCameraRow({
|
||||
camera,
|
||||
onConfigChanged,
|
||||
onDragEnd,
|
||||
}: EnabledCameraRowProps) {
|
||||
setRestartDialogOpen,
|
||||
}: ActiveCameraRowProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const controls = useDragControls();
|
||||
|
||||
@@ -506,38 +439,226 @@ function EnabledCameraRow({
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
</div>
|
||||
<CameraEnableSwitch cameraName={camera} />
|
||||
<CameraStatusSelect
|
||||
cameraName={camera}
|
||||
isDisabledInConfig={false}
|
||||
onConfigChanged={onConfigChanged}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraEnableSwitchProps = {
|
||||
cameraName: string;
|
||||
type DisabledCameraRowProps = {
|
||||
camera: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
|
||||
const { payload: enabledState, send: sendEnabled } =
|
||||
useEnabledState(cameraName);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const isChecked =
|
||||
enabledState === "ON" || enabledState === "OFF"
|
||||
? enabledState === "ON"
|
||||
: (config?.cameras?.[cameraName]?.enabled ?? false);
|
||||
|
||||
function DisabledCameraRow({
|
||||
camera,
|
||||
onConfigChanged,
|
||||
setRestartDialogOpen,
|
||||
}: DisabledCameraRowProps) {
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id={`camera-enabled-${cameraName}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendEnabled(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<CameraNameLabel camera={camera} className="text-muted-foreground" />
|
||||
<CameraDetailsEditor
|
||||
cameraName={camera}
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
</div>
|
||||
<CameraStatusSelect
|
||||
cameraName={camera}
|
||||
isDisabledInConfig={true}
|
||||
onConfigChanged={onConfigChanged}
|
||||
setRestartDialogOpen={setRestartDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraStatus = "on" | "off" | "disabled";
|
||||
|
||||
type CameraStatusSelectProps = {
|
||||
cameraName: string;
|
||||
isDisabledInConfig: boolean;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
function CameraStatusSelect({
|
||||
cameraName,
|
||||
isDisabledInConfig,
|
||||
onConfigChanged,
|
||||
setRestartDialogOpen,
|
||||
}: CameraStatusSelectProps) {
|
||||
const { t } = useTranslation([
|
||||
"views/settings",
|
||||
"components/dialog",
|
||||
"common",
|
||||
]);
|
||||
const { payload: enabledState, send: sendEnabled } =
|
||||
useEnabledState(cameraName);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const currentStatus: CameraStatus = isDisabledInConfig
|
||||
? "disabled"
|
||||
: enabledState === "OFF"
|
||||
? "off"
|
||||
: "on";
|
||||
|
||||
const restartLabel = t("configForm.restartRequiredField", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Restart required",
|
||||
});
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (newStatus: string) => {
|
||||
if (newStatus === currentStatus || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStatus === "on" && !isDisabledInConfig) {
|
||||
sendEnabled("ON");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStatus === "off" && !isDisabledInConfig) {
|
||||
sendEnabled("OFF");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStatus === "on" && isDisabledInConfig) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 1,
|
||||
config_data: {
|
||||
cameras: { [cameraName]: { enabled: true } },
|
||||
},
|
||||
});
|
||||
await onConfigChanged();
|
||||
toast.success(
|
||||
t("cameraManagement.streams.enableSuccess", {
|
||||
ns: "views/settings",
|
||||
cameraName,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a onClick={() => setRestartDialogOpen(true)}>
|
||||
<Button>
|
||||
{t("restart.button", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.data?.message || error.response?.data?.detail)
|
||||
? error.response?.data?.message || error.response?.data?.detail
|
||||
: t("toast.save.error.noMessage", { ns: "common" });
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStatus === "disabled" && !isDisabledInConfig) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Stop runtime processing immediately before persisting the
|
||||
// disable so the camera stops working without waiting for
|
||||
// a restart. The config write below makes the change durable.
|
||||
sendEnabled("OFF");
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: { [cameraName]: { enabled: false } },
|
||||
},
|
||||
});
|
||||
await onConfigChanged();
|
||||
toast.success(
|
||||
t("cameraManagement.streams.disableSuccess", {
|
||||
ns: "views/settings",
|
||||
cameraName,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.data?.message || error.response?.data?.detail)
|
||||
? error.response?.data?.message || error.response?.data?.detail
|
||||
: t("toast.save.error.noMessage", { ns: "common" });
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
cameraName,
|
||||
currentStatus,
|
||||
isDisabledInConfig,
|
||||
isSaving,
|
||||
onConfigChanged,
|
||||
sendEnabled,
|
||||
setRestartDialogOpen,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
if (isSaving) {
|
||||
return (
|
||||
<div className="flex h-7 w-[110px] flex-row items-center justify-end">
|
||||
<ActivityIndicator className="size-4" size={16} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={currentStatus} onValueChange={handleChange}>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="on">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{t("cameraManagement.streams.status.on")}
|
||||
{isDisabledInConfig && (
|
||||
<LuRefreshCcw
|
||||
className="size-3 text-muted-foreground"
|
||||
aria-label={restartLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
{!isDisabledInConfig && (
|
||||
<SelectItem value="off">
|
||||
{t("cameraManagement.streams.status.off")}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="disabled">
|
||||
{t("cameraManagement.streams.status.disabled")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraDetailsEditorProps = {
|
||||
cameraName: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
@@ -783,97 +904,6 @@ function CameraDetailsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
type CameraConfigEnableSwitchProps = {
|
||||
cameraName: string;
|
||||
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function CameraConfigEnableSwitch({
|
||||
cameraName,
|
||||
onConfigChanged,
|
||||
setRestartDialogOpen,
|
||||
}: CameraConfigEnableSwitchProps) {
|
||||
const { t } = useTranslation([
|
||||
"common",
|
||||
"views/settings",
|
||||
"components/dialog",
|
||||
]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const onCheckedChange = useCallback(
|
||||
async (isChecked: boolean) => {
|
||||
if (!isChecked || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 1,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[cameraName]: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await onConfigChanged();
|
||||
|
||||
toast.success(
|
||||
t("cameraManagement.streams.enableSuccess", {
|
||||
ns: "views/settings",
|
||||
cameraName,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a onClick={() => setRestartDialogOpen(true)}>
|
||||
<Button>
|
||||
{t("restart.button", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.data?.message || error.response?.data?.detail)
|
||||
? error.response?.data?.message || error.response?.data?.detail
|
||||
: t("toast.save.error.noMessage", { ns: "common" });
|
||||
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[cameraName, isSaving, onConfigChanged, setRestartDialogOpen, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
{isSaving ? (
|
||||
<ActivityIndicator className="h-5 w-8" size={16} />
|
||||
) : (
|
||||
<Switch
|
||||
id={`camera-enabled-${cameraName}`}
|
||||
checked={false}
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraTypeSectionProps = {
|
||||
cameras: string[];
|
||||
config: FrigateConfig | undefined;
|
||||
@@ -1231,12 +1261,12 @@ function ProfileCameraEnableSection({
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="enabled">
|
||||
{t("cameraManagement.profiles.enabled", {
|
||||
{t("cameraManagement.profiles.on", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="disabled">
|
||||
{t("cameraManagement.profiles.disabled", {
|
||||
{t("cameraManagement.profiles.off", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</SelectItem>
|
||||
|
||||
Reference in New Issue
Block a user