Compare commits

...

12 Commits

Author SHA1 Message Date
gwmullin
16b06e993e
Merge c575fb223b into bc65713ae4 2026-05-29 02:26:45 +02:00
Josh Hawkins
bc65713ae4
Clone camera settings (#23339)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add clone dialog

* i18n

* tweaks

* add to camera management pane

* add e2e test

* optional disable portal prop

* radio and checkbox tweaks

* tweak i18n

* add select all/select none

* fixes

* reset form only on open transition

* unselect all targets for existing camera

* fix test

* reorder sections for save and collapse to single put for new camera

* change source and allow cloning to multiple cameras

* tweak language

* fix overflowing text in save all popover

* tweaks

* fix per label object masks

* use grid for source and target

* language tweak
2026-05-28 17:44:06 -06:00
Josh Hawkins
50f17e6852
Add live streams widget (#23330)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* add live streams widget

* i18n

* docs
2026-05-27 14:35:07 -05:00
Josh Hawkins
e9ef4f978a
Restore runtime state on startup (#23326)
* add class

* restore runtime state in dispatcher

* restore on startup with special case for profile

* add tests

* update docs

* mypy
2026-05-27 12:03:09 -06:00
Greg
c575fb223b Editor fail, re-ruff format. 2026-05-18 14:18:17 -07:00
Greg
9fa345f192 Remove 2x unnecessary index on reviewsegment, remove reference to prior code implementation in comment in event.py 2026-05-18 14:16:43 -07:00
Gdub
7b55c4b758 Rerun ruff formatting. 2026-05-18 13:39:12 -07:00
Gdub
570e2e3f76 Slightly simplify review logic and avoid duplicating the json response for empty review IDs. 2026-05-18 13:30:46 -07:00
Greg
39fba9b0a7 Use peewee instead of rw sql for the CTE query. 2026-05-11 16:46:43 -07:00
Greg
328a26b169 Collapse a few sequential queries into a single one. 2026-05-11 15:45:35 -07:00
Greg
311fb1bd19 Rewrite to use a CTE to leverage speedups by using sqllite internal optimization to do a single query instead of a starter query to get distinct labels and a subsequent loop of querys per distinct event labels.
Frigate is currently shipping sqlite 3.46.1, which is above the minimum version 3.25 needed for CTEs.
2026-05-08 16:18:37 -07:00
Greg
48b1426891 Add additional indicies on event and review tables. Every events or timeline endpoint filters on event start time and camera, this should speed things up by avoiding a range scan on the table. 2026-05-08 15:59:23 -07:00
25 changed files with 3473 additions and 142 deletions

View File

@ -88,8 +88,18 @@ Configure a "friendly name" for your stream followed by the go2rtc stream name.
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" />, then select your camera.
- Under **Live stream names**, add entries mapping a friendly name to each go2rtc stream name (e.g., `Main Stream` mapped to `test_cam`, `Sub Stream` mapped to `test_cam_sub`).
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" /> and select your camera.
2. Under **Live stream names**, click **Add stream** to add a new entry.
3. In the **Stream name** field, enter a friendly name that will appear in the Live UI's stream dropdown (e.g., `Main Stream`).
4. In the **go2rtc stream** field, open the dropdown and select the go2rtc stream this name should map to (e.g., `test_cam`). The dropdown lists every stream configured under `go2rtc.streams`. If the go2rtc stream hasn't been created yet, you can type the name and choose **Use "..."** to save a custom value.
5. Repeat for each additional stream you want to expose (e.g., `Sub Stream``test_cam_sub`).
6. Use the trash icon on a row to remove a stream, then **Save** the section.
:::tip
Configure your go2rtc streams first under <NavPath path="Settings > System > go2rtc streams" /> so the dropdown is populated with valid options.
:::
</TabItem>
<TabItem value="yaml">
@ -262,7 +272,7 @@ cameras:
Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**:
- **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.
- **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. The Off state persists across Frigate restarts via a `.runtime_state.json` file alongside `config.yml` (see [Runtime toggle persistence](#runtime-toggle-persistence)).
- **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.**
#### Turning a camera on or off
@ -290,6 +300,15 @@ For both Off and Disabled cameras, go2rtc remains active but does not use system
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.
#### Runtime toggle persistence
The Live view toggles for **camera on/off**, **detect**, **recordings**, **snapshots**, and **audio detection** — along with the equivalent MQTT `/set` topics — write the new state to `.runtime_state.json` next to your `config.yml`. The file is replayed on Frigate startup so your last-known toggle states survive a restart. Two interactions worth knowing:
- **Settings UI saves win.** When you save a field through **Settings → Global configuration**, the matching entry is cleared from `.runtime_state.json` so the new value in your config file is the durable source.
- **Switching profiles clears all runtime overrides.** Activating or deactivating a [profile](/configuration/profiles) is treated as a deliberate state change, so the file is wiped to avoid stale overrides replaying on top of the new profile.
If you hand-edit `config.yml` while runtime overrides exist, the overrides will still replay on restart. Delete `.runtime_state.json` to reset to the YAML-defined defaults.
### Live player error messages
When your browser runs into problems playing back your camera streams, it will log short error messages to the browser console. They indicate playback, codec, or network issues on the client/browser side, not something server side with Frigate itself. Below are the common messages you may see and simple actions you can take to try to resolve them.

View File

@ -130,6 +130,8 @@ Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integratio
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.
Activating or deactivating a profile clears any [runtime toggle overrides](/configuration/live#runtime-toggle-persistence) so the profile's settings aren't silently undone by a stale toggle from before the switch.
## Example: Home / Away Setup
A common use case is having different detection and notification settings based on whether you are home or away. This example below is for a system with two cameras, `front_door` and `indoor_cam`.

View File

@ -368,7 +368,7 @@ 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 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.
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). To permanently change the configured value, 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`
@ -376,7 +376,7 @@ Topic with current runtime state of processing for a camera. Published values ar
### `frigate/<camera_name>/detect/set`
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/detect/state`
@ -384,7 +384,7 @@ Topic with current state of object detection for a camera. Published values are
### `frigate/<camera_name>/audio/set`
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/audio/state`
@ -392,7 +392,7 @@ Topic with current state of audio detection for a camera. Published values are `
### `frigate/<camera_name>/recordings/set`
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/recordings/state`
@ -400,7 +400,7 @@ Topic with current state of recordings for a camera. Published values are `ON` a
### `frigate/<camera_name>/snapshots/set`
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/snapshots/state`

View File

@ -908,6 +908,11 @@ def config_set(request: Request, body: AppConfigSetBody):
status_code=500,
)
# drop runtime overrides for any fields the user just rewrote in
# yaml so a stale override doesn't silently win after restart
if request.app.dispatcher is not None:
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config

View File

@ -389,82 +389,106 @@ def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events
distinct_labels = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
.order_by(Event.label)
if not allowed_cameras:
return JSONResponse(content=[])
explore_columns = (
Event.id,
Event.camera,
Event.label,
Event.sub_label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
)
label_counts = {}
def event_generator():
for label_obj in distinct_labels.iterator():
label = label_obj.label
# get most recent events for this label
label_events = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc())
.limit(limit)
.iterator()
)
# count total events for this label
label_counts[label] = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.count()
)
yield from label_events
def process_events():
for event in event_generator():
processed_event = {
"id": event.id,
"camera": event.camera,
"label": event.label,
"zones": event.zones,
"start_time": event.start_time,
"end_time": event.end_time,
"has_clip": event.has_clip,
"has_snapshot": event.has_snapshot,
"plus_id": event.plus_id,
"retain_indefinitely": event.retain_indefinitely,
"sub_label": event.sub_label,
"top_score": event.top_score,
"false_positive": event.false_positive,
"box": event.box,
"data": {
k: v
for k, v in event.data.items()
if k
in [
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
"path_data",
"recognized_license_plate",
"recognized_license_plate_score",
]
},
"event_count": label_counts[event.label],
}
yield processed_event
# convert iterator to list and sort
processed_events = sorted(
process_events(),
key=lambda x: (x["event_count"], x["start_time"]),
reverse=True,
# Single query: per-label COUNT and top-N ranking by start_time computed
# via window functions in a CTE, then filtered to rn <= limit
event_count = (
fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count")
)
rn = (
fn.ROW_NUMBER()
.over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
.alias("rn")
)
base_query = Event.select(
*explore_columns,
event_count,
rn,
).where(Event.camera << allowed_cameras)
ranked = base_query.cte("ranked")
query = (
Event.select(
ranked.c.id,
ranked.c.camera,
ranked.c.label,
ranked.c.sub_label,
ranked.c.zones,
ranked.c.start_time,
ranked.c.end_time,
ranked.c.has_clip,
ranked.c.has_snapshot,
ranked.c.plus_id,
ranked.c.retain_indefinitely,
ranked.c.top_score,
ranked.c.false_positive,
ranked.c.box,
ranked.c.data,
ranked.c.event_count,
)
.from_(ranked)
.with_cte(ranked)
.where(ranked.c.rn <= limit)
.order_by(ranked.c.event_count.desc(), ranked.c.start_time.desc())
.objects()
)
allowed_data_keys = {
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
"path_data",
"recognized_license_plate",
"recognized_license_plate_score",
}
processed_events = [
{
"id": event.id,
"camera": event.camera,
"label": event.label,
"zones": event.zones,
"start_time": event.start_time,
"end_time": event.end_time,
"has_clip": event.has_clip,
"has_snapshot": event.has_snapshot,
"plus_id": event.plus_id,
"retain_indefinitely": event.retain_indefinitely,
"sub_label": event.sub_label,
"top_score": event.top_score,
"false_positive": event.false_positive,
"box": event.box,
"data": {
k: v for k, v in (event.data or {}).items() if k in allowed_data_keys
},
"event_count": event.event_count,
}
for event in query
]
return JSONResponse(content=processed_events)
@ -487,22 +511,18 @@ async def event_ids(ids: str, request: Request):
status_code=400,
)
for event_id in ids:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
# we should not fail the entire request if an event is not found
continue
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return JSONResponse(list(events))
events = list(Event.select().where(Event.id << ids).dicts().iterator())
except Exception:
return JSONResponse(
content=({"success": False, "message": "Events not found"}), status_code=400
)
for event in events:
await require_camera_access(event["camera"], request=request)
return JSONResponse(events)
@router.get(
"/events/search",

View File

@ -10,7 +10,7 @@ import pandas as pd
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import (
@ -172,11 +172,19 @@ async def review_ids(request: Request, ids: str):
status_code=400,
)
try:
reviews = list(
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
found_ids = {r["id"] for r in reviews}
for review_id in ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
except DoesNotExist:
if review_id not in found_ids:
return JSONResponse(
content=(
{"success": False, "message": f"Review {review_id} not found"}
@ -184,16 +192,10 @@ async def review_ids(request: Request, ids: str):
status_code=404,
)
try:
reviews = (
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
return JSONResponse(list(reviews))
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
for review in reviews:
await require_camera_access(review["camera"], request=request)
return JSONResponse(reviews)
@router.get(
@ -490,27 +492,52 @@ async def set_multiple_reviewed(
user_id = current_user["username"]
for review_id in body.ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
review_status = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id,
reviews = list(
ReviewSegment.select(ReviewSegment.id, ReviewSegment.camera).where(
ReviewSegment.id << body.ids
)
)
for review in reviews:
await require_camera_access(review.camera, request=request)
found_ids = [r.id for r in reviews]
if found_ids:
existing_statuses = list(
UserReviewStatus.select().where(
(UserReviewStatus.user_id == user_id)
& (UserReviewStatus.review_segment << found_ids)
)
# Update based on the reviewed parameter
if review_status.has_been_reviewed != body.reviewed:
review_status.has_been_reviewed = body.reviewed
review_status.save()
except DoesNotExist:
try:
UserReviewStatus.create(
user_id=user_id,
review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=body.reviewed,
)
status_by_review = {s.review_segment_id: s for s in existing_statuses}
to_update = []
to_create = []
for review_id in found_ids:
if review_id in status_by_review:
status = status_by_review[review_id]
if status.has_been_reviewed != body.reviewed:
status.has_been_reviewed = body.reviewed
to_update.append(status)
else:
to_create.append(
{
"user_id": user_id,
"review_segment_id": review_id,
"has_been_reviewed": body.reviewed,
}
)
except (DoesNotExist, IntegrityError):
pass
if to_update:
UserReviewStatus.bulk_update(
to_update, fields=[UserReviewStatus.has_been_reviewed], batch_size=100
)
if to_create:
UserReviewStatus.insert_many(to_create).execute()
return JSONResponse(
content=(

View File

@ -348,7 +348,11 @@ class FrigateApp:
persisted in cam.profiles for cam in self.config.cameras.values()
):
logger.info("Restoring persisted profile '%s'", persisted)
self.profile_manager.activate_profile(persisted)
# don't clear runtime overrides here, restore_runtime_state() later
# in startup replays it on top of the activated profile
self.profile_manager.activate_profile(
persisted, clear_runtime_overrides=False
)
def start_detectors(self) -> None:
for name in self.config.cameras.keys():
@ -612,6 +616,9 @@ class FrigateApp:
self.start_record_cleanup()
self.start_watchdog()
# restore persisted runtime overrides on top of config
self.dispatcher.restore_runtime_state()
self.init_auth()
try:

View File

@ -3,11 +3,13 @@
import datetime
import json
import logging
from collections.abc import Iterable
from typing import Any, Callable, Optional, cast
from frigate.camera import PTZMetrics
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
from frigate.comms.base_communicator import Communicator
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.comms.webpush import WebPushClient
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.config.camera.updater import (
@ -67,6 +69,7 @@ class Dispatcher:
self.embeddings_reindex: dict[str, Any] = {}
self.birdseye_layout: dict[str, Any] = {}
self.audio_transcription_state: str = "idle"
self._runtime_state = RuntimeStatePersistence()
self._camera_settings_handlers: dict[str, Callable] = {
"audio": self._on_audio_command,
"audio_transcription": self._on_audio_transcription_command,
@ -397,6 +400,60 @@ class Dispatcher:
for comm in self.comms:
comm.stop()
def restore_runtime_state(self) -> None:
"""Replay persisted runtime overrides through the camera settings handlers.
Called once after Frigate startup completes so processing threads can
receive the resulting ``config_updater`` broadcasts. Unknown cameras
and topics are skipped; handler exceptions are logged and replay
continues for remaining entries.
"""
state = self._runtime_state.load()
for camera_name, features in state.items():
if camera_name not in self.config.cameras:
continue
for topic, value in features.items():
handler = self._camera_settings_handlers.get(topic)
if handler is None:
continue
payload = "ON" if value else "OFF"
try:
handler(camera_name, payload)
except Exception:
logger.exception(
"Failed to restore runtime state %s.%s=%s",
camera_name,
topic,
payload,
)
continue
logger.info(
"Restored runtime state: %s.%s=%s",
camera_name,
topic,
payload,
)
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
"""Clear stored runtime overrides for YAML keys that were just rewritten.
Called by ``/api/config/set`` after a successful YAML save so an
explicit settings-UI save isn't silently overridden by an older
runtime toggle on the next restart.
"""
self._runtime_state.clear_for_yaml_keys(dotted_keys)
def clear_runtime_state(self) -> None:
"""Wipe every stored runtime override.
Called when a profile is activated or deactivated. A profile switch
changes the layer below the runtime overrides, so the stored
"steady state" is no longer valid and must be reset; otherwise a
subsequent restart would replay stale overrides on top of the new
profile-derived in-memory state.
"""
self._runtime_state.clear_all()
def _on_detect_command(self, camera_name: str, payload: str) -> None:
"""Callback for detect topic."""
detect_settings = self.config.cameras[camera_name].detect
@ -428,6 +485,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name),
detect_settings,
)
self._runtime_state.set(camera_name, "detect", detect_settings.enabled)
self.publish(f"{camera_name}/detect/state", payload, retain=True)
def _on_enabled_command(self, camera_name: str, payload: str) -> None:
@ -452,6 +510,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name),
camera_settings.enabled,
)
self._runtime_state.set(camera_name, "enabled", camera_settings.enabled)
self.publish(f"{camera_name}/enabled/state", payload, retain=True)
def _on_motion_command(self, camera_name: str, payload: str) -> None:
@ -614,6 +673,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name),
audio_settings,
)
self._runtime_state.set(camera_name, "audio", audio_settings.enabled)
self.publish(f"{camera_name}/audio/state", payload, retain=True)
def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None:
@ -670,6 +730,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name),
record_settings,
)
self._runtime_state.set(camera_name, "recordings", record_settings.enabled)
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
@ -689,6 +750,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name),
snapshots_settings,
)
self._runtime_state.set(camera_name, "snapshots", snapshots_settings.enabled)
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None:

View File

@ -0,0 +1,163 @@
"""Persistence layer for dispatcher runtime state overrides."""
import json
import logging
import os
from collections.abc import Iterable
from typing import Any
from filelock import FileLock, Timeout
from frigate.util.config import find_config_file
logger = logging.getLogger(__name__)
class RuntimeStatePersistence:
"""Persist last-known runtime states for dispatcher toggles.
Stores boolean overrides applied to camera-level toggles by the dispatcher.
Overrides are replayed at startup on top of the YAML-derived in-memory
config, so changes made via MQTT or the live-view UI survive a restart.
"""
# Maps dispatcher topic name -> YAML key suffix under cameras.<cam>
TRACKED_TOPICS: dict[str, str] = {
"enabled": "enabled",
"detect": "detect.enabled",
"snapshots": "snapshots.enabled",
"recordings": "record.enabled",
"audio": "audio.enabled",
}
_SUFFIX_TO_TOPIC: dict[str, str] = {v: k for k, v in TRACKED_TOPICS.items()}
def __init__(self) -> None:
self._path = os.path.join(
os.path.dirname(find_config_file()), ".runtime_state.json"
)
self._lock_path = f"{self._path}.lock"
self._lock_timeout = 5
def load(self) -> dict[str, dict[str, bool]]:
"""Return {camera: {topic: bool}} or {} if missing/corrupt."""
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
except Timeout:
logger.error("Timed out acquiring runtime state lock for load")
return {}
cameras = data.get("cameras", {})
if not isinstance(cameras, dict):
return {}
# Filter out malformed camera entries so callers can trust the shape.
return {
name: features
for name, features in cameras.items()
if isinstance(features, dict)
}
def set(self, camera: str, topic: str, value: bool) -> None:
"""Persist a single (camera, topic, value). No-op if topic untracked."""
if topic not in self.TRACKED_TOPICS:
return
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
cameras = data.setdefault("cameras", {})
if not isinstance(cameras, dict):
cameras = {}
data["cameras"] = cameras
cam = cameras.setdefault(camera, {})
if not isinstance(cam, dict):
cam = {}
cameras[camera] = cam
cam[topic] = bool(value)
self._write_locked(data)
except Timeout:
logger.error("Timed out persisting runtime state for %s/%s", camera, topic)
except OSError:
logger.exception("Failed to persist runtime state for %s/%s", camera, topic)
def clear_all(self) -> None:
"""Wipe every stored runtime override.
Called when the "layer below" changes in a way that invalidates all
runtime overrides for the current session (currently: profile
activation or deactivation).
"""
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
if not os.path.exists(self._path):
return
self._write_locked({"cameras": {}})
except Timeout:
logger.error("Timed out clearing runtime state")
except OSError:
logger.exception("Failed to clear runtime state")
def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
"""Remove stored entries whose YAML key was just rewritten.
Each dotted key must be of the form ``cameras.<camera>.<suffix>``.
Keys that don't match a tracked topic are ignored.
"""
to_remove: list[tuple[str, str]] = []
for key in dotted_keys:
parts = key.split(".")
if len(parts) < 3 or parts[0] != "cameras":
continue
camera = parts[1]
suffix = ".".join(parts[2:])
topic = self._SUFFIX_TO_TOPIC.get(suffix)
if topic is not None:
to_remove.append((camera, topic))
if not to_remove:
return
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
cameras = data.get("cameras")
if not isinstance(cameras, dict):
return
changed = False
for camera, topic in to_remove:
cam = cameras.get(camera)
if isinstance(cam, dict) and topic in cam:
del cam[topic]
changed = True
if not cam:
del cameras[camera]
if changed:
self._write_locked(data)
except Timeout:
logger.error("Timed out clearing runtime state for YAML keys")
except OSError:
logger.exception("Failed to clear runtime state for YAML keys")
def _read_locked(self) -> dict[str, Any]:
"""Read the JSON file while the FileLock is held.
Returns ``{}`` on a missing or corrupt file so the caller can write a
fresh structure on the next mutation.
"""
if not os.path.exists(self._path):
return {}
try:
with open(self._path, "r") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
logger.exception(
"Failed to read runtime state file %s; starting fresh", self._path
)
return {}
return data if isinstance(data, dict) else {}
def _write_locked(self, data: dict[str, Any]) -> None:
"""Atomically write the JSON file while the FileLock is held."""
tmp_path = f"{self._path}.tmp"
with open(tmp_path, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
os.replace(tmp_path, self._path)

View File

@ -124,11 +124,24 @@ class ProfileManager:
self.config.active_profile = None
self._persist_active_profile(None)
def activate_profile(self, profile_name: Optional[str]) -> Optional[str]:
# drop all runtime overrides so they don't replay stale values on restart
if self.dispatcher is not None:
self.dispatcher.clear_runtime_state()
def activate_profile(
self,
profile_name: Optional[str],
clear_runtime_overrides: bool = True,
) -> Optional[str]:
"""Activate a profile by name, or deactivate if None.
Args:
profile_name: Profile name to activate, or None to deactivate.
clear_runtime_overrides: When True (the default, for user-initiated
activations) drop the dispatcher's runtime override file because
the layer below changed. Startup callers that are replaying a
persisted profile pass False so the runtime state stays
available for the subsequent replay step.
Returns:
None on success, or an error message string on failure.
@ -156,6 +169,11 @@ class ProfileManager:
self.config.active_profile = profile_name
self._persist_active_profile(profile_name)
# a profile switch invalidates the steady-state runtime overrides
if clear_runtime_overrides and self.dispatcher is not None:
self.dispatcher.clear_runtime_state()
logger.info(
"Profile %s",
f"'{profile_name}' activated" if profile_name else "deactivated",

View File

@ -0,0 +1,217 @@
"""Tests for Dispatcher runtime state persistence wiring."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.runtime_state import RuntimeStatePersistence
def _make_camera_mock(
*,
enabled: bool = True,
enabled_in_config: bool = True,
detect_enabled: bool = True,
record_enabled: bool = True,
record_enabled_in_config: bool = True,
snapshots_enabled: bool = True,
audio_enabled: bool = True,
audio_enabled_in_config: bool = True,
) -> MagicMock:
"""Build a camera config mock with the fields the in-scope handlers read."""
camera = MagicMock()
camera.enabled = enabled
camera.enabled_in_config = enabled_in_config
camera.detect.enabled = detect_enabled
camera.motion.enabled = True # avoid the detect→motion side-effect path
camera.record.enabled = record_enabled
camera.record.enabled_in_config = record_enabled_in_config
camera.snapshots.enabled = snapshots_enabled
camera.audio.enabled = audio_enabled
camera.audio.enabled_in_config = audio_enabled_in_config
return camera
def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher:
"""Construct a Dispatcher with the bare-minimum mocks the tests need."""
config = MagicMock()
config.cameras = cameras
config_updater = MagicMock()
onvif = MagicMock()
ptz_metrics: dict = {}
communicators: list = []
with (
patch("frigate.comms.dispatcher.CameraActivityManager"),
patch("frigate.comms.dispatcher.AudioActivityManager"),
):
return Dispatcher(config, config_updater, onvif, ptz_metrics, communicators)
class TestRestoreRuntimeState(unittest.TestCase):
"""Verify replay routes through handlers and tolerates missing entries."""
def setUp(self) -> None:
self.dispatcher = _build_dispatcher(
{
"front_door": _make_camera_mock(),
"back_yard": _make_camera_mock(),
}
)
# Swap each in-scope handler for a MagicMock so we can assert calls
# without exercising the handler's own logic.
self.handler_mocks: dict[str, MagicMock] = {}
for topic in ("enabled", "detect", "snapshots", "recordings", "audio"):
mock = MagicMock()
self.dispatcher._camera_settings_handlers[topic] = mock
self.handler_mocks[topic] = mock
def test_replays_each_stored_entry_through_its_handler(self) -> None:
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(
return_value={
"front_door": {"detect": False, "recordings": False},
"back_yard": {"audio": False},
}
),
)
self.dispatcher.restore_runtime_state()
self.handler_mocks["detect"].assert_called_once_with("front_door", "OFF")
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
self.handler_mocks["audio"].assert_called_once_with("back_yard", "OFF")
self.handler_mocks["enabled"].assert_not_called()
self.handler_mocks["snapshots"].assert_not_called()
def test_skips_unknown_cameras(self) -> None:
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(return_value={"removed_cam": {"detect": False}}),
)
self.dispatcher.restore_runtime_state()
for mock in self.handler_mocks.values():
mock.assert_not_called()
def test_skips_unknown_topics(self) -> None:
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(return_value={"front_door": {"some_old_topic": True}}),
)
self.dispatcher.restore_runtime_state()
for mock in self.handler_mocks.values():
mock.assert_not_called()
def test_continues_after_handler_exception(self) -> None:
self.handler_mocks["detect"].side_effect = RuntimeError("boom")
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(
return_value={
"front_door": {"detect": False, "recordings": False},
}
),
)
# Must not raise; the recordings handler must still run.
self.dispatcher.restore_runtime_state()
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
def test_true_value_routes_as_on_payload(self) -> None:
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(return_value={"front_door": {"detect": True}}),
)
self.dispatcher.restore_runtime_state()
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
class TestHandlersPersistViaSet(unittest.TestCase):
"""Verify each in-scope handler writes to the runtime state on success."""
def setUp(self) -> None:
self.tmp_dir = tempfile.mkdtemp()
self.config_path = os.path.join(self.tmp_dir, "config.yml")
with open(self.config_path, "w") as f:
f.write("")
self._patcher = patch(
"frigate.comms.runtime_state.find_config_file",
return_value=self.config_path,
)
self._patcher.start()
# Start with everything OFF so each ON payload triggers a real change
self.cameras = {
"front_door": _make_camera_mock(
enabled=False,
detect_enabled=False,
record_enabled=False,
snapshots_enabled=False,
audio_enabled=False,
)
}
self.dispatcher = _build_dispatcher(self.cameras)
def tearDown(self) -> None:
self._patcher.stop()
for name in os.listdir(self.tmp_dir):
os.remove(os.path.join(self.tmp_dir, name))
os.rmdir(self.tmp_dir)
def _stored_state(self) -> dict:
return RuntimeStatePersistence().load()
def test_enabled_handler_persists(self) -> None:
self.dispatcher._on_enabled_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"enabled": True}})
def test_detect_handler_persists(self) -> None:
self.dispatcher._on_detect_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"detect": True}})
def test_recordings_handler_persists(self) -> None:
self.dispatcher._on_recordings_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"recordings": True}})
def test_snapshots_handler_persists(self) -> None:
self.dispatcher._on_snapshots_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"snapshots": True}})
def test_audio_handler_persists(self) -> None:
self.dispatcher._on_audio_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"audio": True}})
def test_enabled_in_config_gate_blocks_persistence(self) -> None:
"""An ON payload rejected by the gate must not be persisted."""
cam = self.cameras["front_door"]
cam.enabled_in_config = False
cam.record.enabled_in_config = False
cam.audio.enabled_in_config = False
self.dispatcher._on_enabled_command("front_door", "ON")
self.dispatcher._on_recordings_command("front_door", "ON")
self.dispatcher._on_audio_command("front_door", "ON")
self.assertEqual(self._stored_state(), {})
class TestClearPassthrough(unittest.TestCase):
"""The dispatcher's public clear methods delegate to the store."""
def test_clear_runtime_state_for_yaml_keys_passthrough(self) -> None:
dispatcher = _build_dispatcher({})
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
keys = ["cameras.front_door.detect.enabled"]
dispatcher.clear_runtime_state_for_yaml_keys(keys)
dispatcher._runtime_state.clear_for_yaml_keys.assert_called_once_with(keys)
def test_clear_runtime_state_passthrough(self) -> None:
dispatcher = _build_dispatcher({})
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
dispatcher.clear_runtime_state()
dispatcher._runtime_state.clear_all.assert_called_once_with()
if __name__ == "__main__":
unittest.main()

View File

@ -727,6 +727,55 @@ class TestProfileManager(unittest.TestCase):
# Should not raise
json.dumps(api_base)
@patch.object(ProfileManager, "_persist_active_profile")
def test_activate_profile_clears_dispatcher_runtime_state(self, mock_persist):
"""User-initiated activation drops runtime overrides (steady-state rule)."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_deactivate_profile_clears_dispatcher_runtime_state(self, mock_persist):
"""Deactivating a profile also drops runtime overrides."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
dispatcher.clear_runtime_state.reset_mock()
manager.activate_profile(None)
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
"""Startup callers pass clear_runtime_overrides=False to preserve state."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed", clear_runtime_overrides=False)
dispatcher.clear_runtime_state.assert_not_called()
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist):
"""After /api/config/set, an active-profile re-application drops state."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
dispatcher.clear_runtime_state.reset_mock()
new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config)
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
"""Plain /api/config/set without a profile doesn't trigger the broad clear."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
# No activate_profile call — config.active_profile is None
new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config)
dispatcher.clear_runtime_state.assert_not_called()
class TestProfilePersistence(unittest.TestCase):
"""Test profile persistence to disk."""

View File

@ -0,0 +1,136 @@
"""Tests for RuntimeStatePersistence."""
import json
import os
import tempfile
import unittest
from unittest.mock import patch
from frigate.comms.runtime_state import RuntimeStatePersistence
class TestRuntimeStatePersistence(unittest.TestCase):
"""Unit tests for the JSON-backed runtime state store."""
def setUp(self) -> None:
self.tmp_dir = tempfile.mkdtemp()
self.config_path = os.path.join(self.tmp_dir, "config.yml")
# Touch a placeholder config.yml so find_config_file returns a real path
with open(self.config_path, "w") as f:
f.write("")
self._patcher = patch(
"frigate.comms.runtime_state.find_config_file",
return_value=self.config_path,
)
self._patcher.start()
self.store = RuntimeStatePersistence()
def tearDown(self) -> None:
self._patcher.stop()
for name in os.listdir(self.tmp_dir):
os.remove(os.path.join(self.tmp_dir, name))
os.rmdir(self.tmp_dir)
def test_load_returns_empty_when_file_missing(self) -> None:
self.assertEqual(self.store.load(), {})
def test_set_then_load_round_trip(self) -> None:
self.store.set("front_door", "detect", False)
self.store.set("front_door", "recordings", True)
self.store.set("back_yard", "audio", False)
result = self.store.load()
self.assertEqual(
result,
{
"front_door": {"detect": False, "recordings": True},
"back_yard": {"audio": False},
},
)
def test_set_with_untracked_topic_is_noop(self) -> None:
self.store.set("front_door", "ptz_autotracker", True)
self.assertEqual(self.store.load(), {})
# File should not even be created if no tracked entries were written
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
self.assertFalse(os.path.exists(runtime_path))
def test_set_overwrites_previous_value(self) -> None:
self.store.set("front_door", "detect", True)
self.store.set("front_door", "detect", False)
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
def test_load_returns_empty_when_file_corrupt(self) -> None:
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
with open(runtime_path, "w") as f:
f.write("{not valid json")
self.assertEqual(self.store.load(), {})
def test_load_handles_unexpected_top_level_shape(self) -> None:
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
with open(runtime_path, "w") as f:
json.dump(["unexpected", "list"], f)
self.assertEqual(self.store.load(), {})
def test_clear_for_yaml_keys_removes_matching_entries(self) -> None:
self.store.set("front_door", "detect", False)
self.store.set("front_door", "recordings", False)
self.store.set("back_yard", "audio", False)
self.store.clear_for_yaml_keys(
[
"cameras.front_door.detect.enabled",
"cameras.back_yard.audio.enabled",
]
)
self.assertEqual(
self.store.load(),
{"front_door": {"recordings": False}},
)
def test_clear_for_yaml_keys_collapses_empty_camera_dict(self) -> None:
self.store.set("front_door", "detect", False)
self.store.clear_for_yaml_keys(["cameras.front_door.detect.enabled"])
self.assertEqual(self.store.load(), {})
def test_clear_for_yaml_keys_ignores_unrelated_keys(self) -> None:
self.store.set("front_door", "detect", False)
self.store.clear_for_yaml_keys(
[
"ui.theme",
"go2rtc.streams.x",
"cameras.front_door.ffmpeg.inputs",
"not_cameras.front_door.detect.enabled",
]
)
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
def test_clear_for_yaml_keys_handles_empty_iterable(self) -> None:
self.store.set("front_door", "detect", False)
self.store.clear_for_yaml_keys([])
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
def test_camera_level_enabled_uses_top_level_yaml_key(self) -> None:
"""`enabled` topic maps to the camera-level `cameras.<cam>.enabled` key."""
self.store.set("front_door", "enabled", False)
self.store.clear_for_yaml_keys(["cameras.front_door.enabled"])
self.assertEqual(self.store.load(), {})
def test_clear_all_wipes_every_entry(self) -> None:
self.store.set("front_door", "detect", False)
self.store.set("front_door", "recordings", True)
self.store.set("back_yard", "audio", False)
self.store.clear_all()
self.assertEqual(self.store.load(), {})
def test_clear_all_is_safe_when_file_missing(self) -> None:
# No prior set() calls — file does not exist
self.store.clear_all()
self.assertEqual(self.store.load(), {})
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,27 @@
"""Peewee migrations -- 036_add_perf_indexes.py.
Adds composite/single-column indexes to speed up the most common queries
issued by the web UI on initial page load:
- event(camera, start_time DESC): /events list filtered by camera + time range
- reviewsegment(camera, start_time DESC): /api/review filtered by camera + time range
- reviewsegment(end_time): supports the end_time > after half of /api/review's range
The existing event(label, start_time DESC) index from migration 027 already
covers /events/explore, so it is intentionally not duplicated here.
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE INDEX IF NOT EXISTS "event_camera_start_time" '
'ON "event" ("camera", "start_time" DESC)'
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP INDEX IF EXISTS "event_camera_start_time"')

View File

@ -0,0 +1,181 @@
/**
* Camera clone dialog E2E tests.
*
* Covers the design invariants that don't depend on per-camera resolution
* differences in the mock fixture:
* 1. Dialog opens from the "Clone settings" button below Add/Delete.
* 2. A source camera must be chosen inside the dialog before cloning.
* 3. "Stream URLs and roles" is forced on and disabled for new-camera target.
* 4. Cloning to a new camera issues a single add PUT and shows a restart prompt.
* 5. The existing-camera target selects multiple destinations via a switch
* popover (with an "All cameras" toggle and source exclusion); the closed
* trigger summarizes the selection by name or as "All cameras".
*
* The spatial-mismatch warning path is exercised in unit-level review and via
* manual QA the shared mock fixture ships every camera at 1280×720. The
* existing-camera PUT fan-out is likewise not asserted here: the mock cameras
* are identical apart from stream URLs (which existing-camera clones never
* copy) and the schema mock is empty, so a clone onto them produces no diff
* and no PUT. That path is covered by unit-level review and manual QA.
*/
import { test, expect } from "../fixtures/frigate-test";
async function openCloneDialog(frigateApp: {
page: import("@playwright/test").Page;
}) {
await frigateApp.page
.getByRole("button", { name: /^Clone settings$/i })
.click();
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
}
async function selectSource(
frigateApp: { page: import("@playwright/test").Page },
source: string,
) {
await frigateApp.page.getByRole("dialog").getByRole("combobox").click();
await frigateApp.page
.getByRole("option", { name: source, exact: true })
.click();
}
test.describe("Camera clone dialog @medium @mobile", () => {
test.beforeEach(async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
});
test("opens the dialog from the Clone settings button", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await expect(
frigateApp.page.getByRole("dialog").getByText(/Clone camera settings/i),
).toBeVisible();
// The Clone button is disabled until a source (and target) is chosen.
await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeDisabled();
});
test("forces Stream URLs and roles on for new-camera target", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
// The "New camera" radio is selected by default; the Streams group renders
// the ffmpeg_live checkbox as forced-checked and disabled.
const streamsLabel = frigateApp.page
.locator("label")
.filter({ hasText: /Stream URLs and roles/i });
await expect(streamsLabel).toBeVisible();
const streamsCheckbox = streamsLabel.getByRole("checkbox");
await expect(streamsCheckbox).toBeChecked();
await expect(streamsCheckbox).toBeDisabled();
});
test("issues a single add PUT and shows restart toast for new-camera target", async ({
frigateApp,
}) => {
const requests: { body: unknown }[] = [];
await frigateApp.page.route("**/api/config/set", async (route) => {
const body = route.request().postDataJSON();
requests.push({ body });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, require_restart: false }),
});
});
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
const nameInput = frigateApp.page.getByPlaceholder(
/e\.g\., back_door or Back Door/i,
);
await nameInput.fill("clone_target_one");
// With a source picked and a valid name, changeCount > 0 enables Clone.
await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeEnabled({ timeout: 5_000 });
await frigateApp.page.getByRole("button", { name: /^Clone$/i }).click();
// New-camera clones bundle into a single atomic add PUT (avoids
// per-section validation ordering issues).
await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(1);
const firstBody = requests[0].body as {
requires_restart?: number;
update_topic?: string;
};
expect(firstBody.update_topic).toMatch(
/config\/cameras\/clone_target_one\/add/,
);
expect(firstBody.requires_restart).toBe(1);
// The toast offers a Restart action because new-camera always needs restart.
// .first() avoids strict-mode rejection when both the toast action and the
// RestartDialog trigger render concurrently.
await expect(
frigateApp.page.getByRole("button", { name: /Restart/i }).first(),
).toBeVisible({ timeout: 8_000 });
});
test("selects multiple existing destination cameras via a switch popover", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
await frigateApp.page
.getByRole("radio", { name: /Existing cameras/i })
.click();
const dialog = frigateApp.page.getByRole("dialog");
// The destination trigger starts with the empty-selection placeholder.
await dialog
.getByRole("button", { name: /Select at least one camera/i })
.click();
// The chosen source is excluded from the destination switch list.
await expect(
dialog.getByRole("switch", { name: /Backyard/i }),
).toBeVisible();
await expect(dialog.getByRole("switch", { name: /Garage/i })).toBeVisible();
await expect(
dialog.getByRole("switch", { name: /^Front Door$/i }),
).toHaveCount(0);
// Selecting a single camera summarizes by name once the popover closes.
await dialog.getByRole("switch", { name: /Backyard/i }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(
dialog.getByRole("button", { name: /^Backyard$/i }),
).toBeVisible();
// Reopen and select everything; the trigger collapses to "All cameras".
await dialog.getByRole("button", { name: /^Backyard$/i }).click();
await dialog.getByRole("switch", { name: /^All cameras$/i }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(
dialog.getByRole("button", { name: /^All cameras$/i }),
).toBeVisible();
});
});

View File

@ -544,6 +544,92 @@
"normal": "Normal",
"dedicatedLpr": "Dedicated LPR",
"saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes."
},
"clone": {
"sectionTitle": "Clone settings",
"sectionDescription": "Copy configuration from one camera to another camera or a new one.",
"button": "Clone settings",
"title": "Clone camera settings",
"description": "Copy a camera's configuration to one or more other cameras or a new camera. Identity (name, friendly name, web UI URL, display order) is never copied.",
"source": {
"label": "Source camera",
"placeholder": "Select a source camera",
"required": "Select a source camera"
},
"target": {
"legend": "Target",
"newRadio": "New camera",
"newNameLabel": "Camera name",
"newNamePlaceholder": "e.g., back_door or Back Door",
"newNameRequired": "Camera name is required",
"newNameInvalid": "Invalid camera name",
"newNameCollision": "A camera with this name already exists",
"newStreamsForced": "Streams are always copied for a new camera.",
"existingCamerasRadio": "Existing cameras",
"allCameras": "All cameras",
"existingPlaceholder": "Select at least one camera",
"existingDisabled": "No other cameras to copy to"
},
"categories": {
"legend": "Settings to clone",
"description": "Choose which settings to copy from the source camera.",
"selectAll": "Select all",
"selectNone": "Select none",
"resetDefaults": "Reset to defaults",
"general": "General",
"spatial": "Spatial settings",
"streams": "Streams",
"spatialWarningTitle": "Resolution mismatch",
"spatialWarning": "Source camera {{srcCamera}} detect resolution ({{srcWidth}}×{{srcHeight}}) differs from: {{cameras}}. Polygons may not align on those cameras. These defaults are off; enable to copy as-is.",
"restartHint": "Restart required",
"items": {
"record": "Recording",
"snapshots": "Snapshots",
"review": "Review",
"motion": "Motion detection",
"objects": "Objects",
"audio": "Audio detection",
"audio_transcription": "Audio transcription",
"notifications": "Notifications",
"birdseye": "Birdseye",
"mqtt": "MQTT",
"timestamp_style": "Timestamp style",
"onvif": "ONVIF",
"lpr": "License plate recognition",
"face_recognition": "Face recognition",
"semantic_search": "Semantic search",
"genai": "Generative AI",
"type": "Camera type (normal / dedicated LPR)",
"profiles": "Profiles",
"detect": "Detect dimensions",
"zones": "Zones",
"motion_mask": "Motion masks",
"object_masks": "Object masks",
"ffmpeg_live": "Stream URLs and roles"
}
},
"footer": {
"changeCount_zero": "No changes selected",
"changeCount_one": "{{count}} change will be applied",
"changeCount_other": "{{count}} changes will be applied",
"restartNeeded": "Restart will be required for some changes.",
"liveOnly": "All changes will apply live without a restart.",
"submit": "Clone",
"submitting": "Cloning…"
},
"toast": {
"success": "Settings copied to {{cameraName}}",
"successWithRestart": "Settings copied to {{cameraName}}. Restart Frigate to apply all changes.",
"successMulti_one": "Settings copied to {{count}} camera",
"successMulti_other": "Settings copied to {{count}} cameras",
"successMultiWithRestart_one": "Settings copied to {{count}} camera. Restart Frigate to apply all changes.",
"successMultiWithRestart_other": "Settings copied to {{count}} cameras. Restart Frigate to apply all changes.",
"partialFailure": "{{successCount}} sections applied; '{{failedSection}}' failed: {{errorMessage}}",
"partialFailureMulti": "Copied to {{successCount}} camera(s); failed for {{failed}}: {{errorMessage}}",
"newCameraPartialFailure": "Camera {{cameraName}} was created but some settings failed to copy: {{errorMessage}}",
"sourceMissing": "Source camera no longer exists",
"submitError": "Failed to clone camera: {{errorMessage}}"
}
}
},
"cameraReview": {
@ -1405,6 +1491,17 @@
"namePlaceholder": "e.g., Wife's Car",
"platePlaceholder": "Plate number or regex"
},
"liveStreams": {
"streamNameLabel": "Stream name",
"streamNamePlaceholder": "e.g., Main HD Stream",
"go2rtcStreamLabel": "go2rtc stream",
"go2rtcStreamPlaceholder": "Select a go2rtc stream",
"go2rtcStreamSearch": "Search or enter a stream name…",
"noGo2rtcStreams": "No go2rtc streams configured",
"availableStreams": "Available streams",
"useCustom": "Use \"{{value}}\"",
"addStream": "Add stream"
},
"timezone": {
"defaultOption": "Use browser timezone"
},

View File

@ -4,17 +4,26 @@ const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
restartRequired: [],
fieldOrder: ["stream_name", "height", "quality"],
fieldOrder: ["streams", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality"],
},
global: {
restartRequired: ["stream_name", "height", "quality"],
restartRequired: ["streams", "height", "quality"],
hiddenFields: ["streams"],
},
camera: {
restartRequired: ["height", "quality"],
uiSchema: {
streams: {
"ui:field": "LiveStreamsField",
"ui:options": {
label: false,
suppressDescription: true,
},
},
},
},
};

View File

@ -0,0 +1,346 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
type LiveStreamsData = Record<string, string>;
type StreamValueComboboxProps = {
id: string;
value: string;
options: string[];
disabled?: boolean;
readonly?: boolean;
onChange: (next: string) => void;
};
function StreamValueCombobox({
id,
value,
options,
disabled,
readonly,
onChange,
}: StreamValueComboboxProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const trimmedSearch = searchValue.trim();
const matchesOption = useMemo(
() => options.some((o) => o.toLowerCase() === trimmedSearch.toLowerCase()),
[options, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesOption;
const commit = (next: string) => {
onChange(next);
setSearchValue("");
setOpen(false);
};
const placeholder = t("configForm.liveStreams.go2rtcStreamPlaceholder", {
ns: "views/settings",
});
const searchPlaceholder = t("configForm.liveStreams.go2rtcStreamSearch", {
ns: "views/settings",
});
const noStreams = t("configForm.liveStreams.noGo2rtcStreams", {
ns: "views/settings",
});
const availableHeading = t("configForm.liveStreams.availableStreams", {
ns: "views/settings",
});
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"w-full justify-between font-normal",
!value && "text-muted-foreground",
)}
>
<span className="truncate">{value || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === "Enter" && showCustomOption) {
e.preventDefault();
commit(trimmedSearch);
}
}}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.liveStreams.useCustom", {
ns: "views/settings",
value: trimmedSearch,
})}
</CommandItem>
</CommandGroup>
)}
{options.length > 0 ? (
<CommandGroup heading={availableHeading}>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => commit(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandGroup>
) : !showCustomOption ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{noStreams}
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function LiveStreamsField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const configNamespace =
formContext?.i18nNamespace ??
(formContext?.level === "camera" ? "config/cameras" : "config/global");
const { t: fallbackT } = useTranslation(["common", configNamespace]);
const t = formContext?.t ?? fallbackT;
const data: LiveStreamsData = useMemo(() => {
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
return {};
}
return formData as LiveStreamsData;
}, [formData]);
const entries = useMemo(() => Object.entries(data), [data]);
const id = idSchema?.$id ?? props.name;
const sectionPrefix = formContext?.sectionI18nPrefix;
const title =
t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title;
const description =
t(`${sectionPrefix}.${id}.description`) ??
(schema as RJSFSchema).description;
const go2rtcStreamNames = useMemo<string[]>(() => {
const streams = formContext?.fullConfig?.go2rtc?.streams;
if (!streams || typeof streams !== "object") return [];
return Object.keys(streams).sort();
}, [formContext?.fullConfig?.go2rtc?.streams]);
const emptyPath = useMemo(() => [] as FieldPathList, []);
const fieldPath =
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
emptyPath;
const isModified = useMemo(() => {
const baselineRoot = formContext?.baselineFormData;
const baselineValue = baselineRoot
? get(baselineRoot, fieldPath)
: undefined;
return isSubtreeModified(
data,
baselineValue,
formContext?.overrides,
fieldPath,
formContext?.formData,
);
}, [fieldPath, formContext, data]);
const handleAddEntry = useCallback(() => {
const next = { ...data, "": "" };
onChange(next, fieldPath);
}, [data, fieldPath, onChange]);
const handleRemoveEntry = useCallback(
(key: string) => {
const next = { ...data };
delete next[key];
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleRenameKey = useCallback(
(oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const next: LiveStreamsData = {};
for (const [k, v] of Object.entries(data)) {
if (k === oldKey) {
next[newKey] = v;
} else {
next[k] = v;
}
}
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleUpdateValue = useCallback(
(key: string, value: string) => {
const next = { ...data, [key]: value };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const baseId = idSchema?.$id || "live_streams";
const deleteLabel = t("button.delete", {
ns: "common",
defaultValue: "Delete",
});
const streamNameLabel = t("configForm.liveStreams.streamNameLabel", {
ns: "views/settings",
});
const streamNamePlaceholder = t(
"configForm.liveStreams.streamNamePlaceholder",
{ ns: "views/settings" },
);
const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", {
ns: "views/settings",
});
const addStreamLabel = t("configForm.liveStreams.addStream", {
ns: "views/settings",
});
return (
<Card className="w-full">
<CardHeader className="p-4">
<CardTitle className={cn("text-sm", isModified && "text-unsaved")}>
{title}
</CardTitle>
{description && (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
)}
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{entries.map(([key, value], entryIndex) => {
const entryId = `${baseId}-${entryIndex}`;
return (
<div
key={entryIndex}
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
>
<div className="col-span-12 space-y-2 md:col-span-5">
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
<Input
id={`${entryId}-key`}
defaultValue={key}
placeholder={streamNamePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
/>
</div>
<div className="col-span-10 space-y-2 md:col-span-6">
<Label htmlFor={`${entryId}-value`}>{go2rtcStreamLabel}</Label>
<StreamValueCombobox
id={`${entryId}-value`}
value={value}
options={go2rtcStreamNames}
disabled={disabled}
readonly={readonly}
onChange={(next) => handleUpdateValue(key, next)}
/>
</div>
<div className="col-span-2 flex justify-end md:col-span-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveEntry(key)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddEntry}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{addStreamLabel}
</Button>
</div>
</CardContent>
</Card>
);
}
export default LiveStreamsField;

View File

@ -2,3 +2,4 @@
export { LayoutGridField } from "./LayoutGridField";
export { DetectorHardwareField } from "./DetectorHardwareField";
export { ReplaceRulesField } from "./ReplaceRulesField";
export { LiveStreamsField } from "./LiveStreamsField";

View File

@ -51,6 +51,7 @@ import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField";
import { KnownPlatesField } from "./fields/KnownPlatesField";
import { LiveStreamsField } from "./fields/LiveStreamsField";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
@ -109,5 +110,6 @@ export const frigateTheme: FrigateTheme = {
CameraInputsField: CameraInputsField,
DictAsYamlField: DictAsYamlField,
KnownPlatesField: KnownPlatesField,
LiveStreamsField: LiveStreamsField,
},
};

View File

@ -22,6 +22,7 @@ type SaveAllPreviewPopoverProps = {
className?: string;
align?: "start" | "center" | "end";
side?: "top" | "bottom" | "left" | "right";
disablePortal?: boolean;
};
export default function SaveAllPreviewPopover({
@ -29,6 +30,7 @@ export default function SaveAllPreviewPopover({
className,
align = "end",
side = "bottom",
disablePortal = false,
}: SaveAllPreviewPopoverProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
@ -67,6 +69,7 @@ export default function SaveAllPreviewPopover({
<PopoverContent
align={align}
side={side}
disablePortal={disablePortal}
className="w-[90vw] max-w-sm border bg-background p-4 shadow-lg"
onOpenAutoFocus={(event) => event.preventDefault()}
>
@ -108,13 +111,13 @@ export default function SaveAllPreviewPopover({
}`}
className="rounded-md border border-secondary bg-background_alt p-2"
>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("saveAllPreview.scope.label", {
ns: "views/settings",
})}
</span>
<span className="truncate">{scopeLabel}</span>
<span className="min-w-0 truncate">{scopeLabel}</span>
{item.profileName && (
<>
<span className="text-muted-foreground">
@ -122,7 +125,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="truncate font-medium">
<span className="min-w-0 truncate font-medium">
{item.profileName}
</span>
</>
@ -132,7 +135,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="break-all font-mono">
<span className="min-w-0 break-all font-mono">
{item.fieldPath}
</span>
<span className="text-muted-foreground">
@ -140,7 +143,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="whitespace-pre-wrap break-words font-mono">
<span className="min-w-0 whitespace-pre-wrap break-all font-mono">
{formatValue(item.value)}
</span>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,856 @@
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import merge from "lodash/merge";
import type { RJSFSchema } from "@rjsf/utils";
import {
buildOverrides,
cameraUpdateTopicMap,
flattenOverrides,
getEffectiveAttributeLabels,
getSectionConfig,
prepareSectionSavePayload,
resolveHiddenFieldEntries,
sanitizeSectionData,
type SectionSavePayload,
} from "@/utils/configUtil";
import { applySchemaDefaults } from "@/lib/config-schema";
import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover";
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type {
ConfigSectionData,
JsonObject,
JsonValue,
} from "@/types/configForm";
import { processCameraName } from "@/utils/cameraUtil";
/**
* Sections whose `filters` dict is auto-populated by the backend at parse
* time. `attributeBump` reflects the global-level `min_score=0.7` override
* the backend applies to attribute labels (face, license_plate, Frigate+
* couriers) see `frigate/config/config.py`.
*/
const FILTER_SECTION_DEFS: Record<
string,
{
listField: string;
filterDef: string;
attributeBump?: { min_score: number };
}
> = {
objects: {
listField: "track",
filterDef: "FilterConfig",
attributeBump: { min_score: 0.7 },
},
audio: { listField: "listen", filterDef: "AudioFilterConfig" },
};
function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined {
const defs =
(schema as { $defs?: Record<string, RJSFSchema> }).$defs ??
(schema as { definitions?: Record<string, RJSFSchema> }).definitions;
return defs ? defs[name] : undefined;
}
/**
* Reduce each filter entry to the fields that differ from the backend's
* auto-default. An entry that is entirely auto-populated drops out; a
* partially-customized entry keeps only its customized fields, so cloning
* doesn't copy the auto-populated default for every other field.
*/
function stripAutoDefaultFilters(
section: string,
sourceSection: JsonObject,
fullSchema: RJSFSchema,
fullConfig: FrigateConfig,
fullCameraConfig: CameraConfig,
): JsonObject {
const def = FILTER_SECTION_DEFS[section];
if (!def) return sourceSection;
const filters = sourceSection.filters;
if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
return sourceSection;
}
const filterDef = resolveDef(fullSchema, def.filterDef);
if (!filterDef) return sourceSection;
const baseDefaults = applySchemaDefaults(filterDef, {}) as JsonObject;
const attributeDefaults = def.attributeBump
? ({ ...baseDefaults, ...def.attributeBump } as JsonObject)
: baseDefaults;
const attributeSet =
section === "objects"
? new Set(
getEffectiveAttributeLabels(fullConfig, fullCameraConfig, "camera"),
)
: new Set<string>();
// Ignore runtime-only `mask`/`raw_mask`: the API ships them as `{}` while the
// schema default omits them, which would otherwise break the equality check.
const withoutRuntimeFields = (entry: JsonValue): JsonValue => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return entry;
}
const copy = { ...(entry as JsonObject) };
delete copy.mask;
delete copy.raw_mask;
return copy;
};
const cleaned: JsonObject = {};
for (const [label, value] of Object.entries(filters as JsonObject)) {
const expected = attributeSet.has(label) ? attributeDefaults : baseDefaults;
const valNorm = withoutRuntimeFields(value as JsonValue);
const expNorm = withoutRuntimeFields(expected as JsonValue);
// Non-object filter value: keep only if it differs from the default.
if (
!valNorm ||
typeof valNorm !== "object" ||
Array.isArray(valNorm) ||
!expNorm ||
typeof expNorm !== "object" ||
Array.isArray(expNorm)
) {
if (!isEqual(valNorm, expNorm)) {
cleaned[label] = value as JsonValue;
}
continue;
}
const diff: JsonObject = {};
for (const [field, fieldValue] of Object.entries(valNorm as JsonObject)) {
if (!isEqual(fieldValue, (expNorm as JsonObject)[field])) {
diff[field] = fieldValue as JsonValue;
}
}
if (Object.keys(diff).length > 0) {
cleaned[label] = diff;
}
}
return { ...sourceSection, filters: cleaned };
}
/**
* Strip runtime-only fields from each entry of a dict-of-objects (mask
* `enabled_in_config`/`raw_coordinates`, zone `color`) that clone re-injects
* from the API.
*/
function stripDictEntryFields(
dict: unknown,
fieldsToStrip: readonly string[],
): unknown {
if (!dict || typeof dict !== "object" || Array.isArray(dict)) return dict;
const result: JsonObject = {};
for (const [key, value] of Object.entries(dict as JsonObject)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
const cleaned = { ...(value as JsonObject) };
for (const field of fieldsToStrip) {
delete cleaned[field];
}
result[key] = cleaned as JsonValue;
} else {
result[key] = value as JsonValue;
}
}
return result;
}
/**
* Per-object masks (`objects.filters.<label>.mask`) for the labels that define
* one, stripped of runtime fields. The objects form hides `filters.*.mask`, so
* clone re-injects these like the camera-wide `objects.mask`.
*/
function extractFilterMasks(objectsSection: unknown): JsonObject | undefined {
if (!objectsSection || typeof objectsSection !== "object") return undefined;
const filters = (objectsSection as JsonObject).filters;
if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
return undefined;
}
const result: JsonObject = {};
for (const [label, filter] of Object.entries(filters as JsonObject)) {
if (!filter || typeof filter !== "object" || Array.isArray(filter))
continue;
const mask = (filter as JsonObject).mask;
if (
mask &&
typeof mask === "object" &&
!Array.isArray(mask) &&
Object.keys(mask as JsonObject).length > 0
) {
result[label] = {
mask: stripDictEntryFields(mask, [
"enabled_in_config",
"raw_coordinates",
]) as JsonValue,
};
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Drop `""` (Reset) markers meaningless for a new camera and unsafe
* (backend `update_yaml` raises KeyError trying to `del` a missing key).
*/
function stripResetMarkers(
value: JsonValue | undefined,
): JsonValue | undefined {
if (value === undefined || value === "") return undefined;
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: JsonObject = {};
for (const [key, child] of Object.entries(value as JsonObject)) {
const cleaned = stripResetMarkers(child);
if (cleaned !== undefined) result[key] = cleaned;
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Collapse per-section payloads into one camera-level `…/add` payload. The
* backend's `add` handler validates atomically, avoiding the per-section
* ordering problem (e.g. `review.required_zones` referencing unwritten zones).
*/
function bundleNewCameraPayload(
payloads: SectionSavePayload[],
target: string,
): SectionSavePayload {
const prefix = `cameras.${target}`;
const camera: JsonObject = {};
for (const p of payloads) {
if (p.basePath === prefix) {
merge(camera, p.sanitizedOverrides);
} else if (p.basePath.startsWith(`${prefix}.`)) {
merge(camera, {
[p.basePath.slice(prefix.length + 1)]: p.sanitizedOverrides,
});
}
}
return {
basePath: prefix,
sanitizedOverrides: camera,
updateTopic: `config/cameras/${target}/add`,
needsRestart: true,
pendingDataKey: `${target}::add`,
};
}
/**
* Drop empty `*_args` arrays from ffmpeg inputs the establishing payload
* uses `buildOverrides` directly, bypassing `sanitizeOverridesForSection`.
*/
function cleanupFfmpegInputArgs(
ffmpeg: JsonValue | undefined,
): JsonValue | undefined {
if (!ffmpeg || typeof ffmpeg !== "object" || Array.isArray(ffmpeg)) {
return ffmpeg;
}
const obj = ffmpeg as JsonObject;
const inputs = obj.inputs;
if (!Array.isArray(inputs)) return ffmpeg;
const cleanedInputs = inputs.map((input) => {
if (!input || typeof input !== "object" || Array.isArray(input))
return input;
const cleaned = { ...(input as JsonObject) };
for (const argsKey of ["global_args", "hwaccel_args", "input_args"]) {
const v = cleaned[argsKey];
if (Array.isArray(v) && v.length === 0) delete cleaned[argsKey];
}
return cleaned as JsonValue;
});
return { ...obj, inputs: cleanedInputs as JsonValue };
}
/** Subset of `/api/config/raw_paths` used to unmask source credentials. */
export type RawCameraPaths = {
cameras?: Record<
string,
{ ffmpeg?: { inputs?: Array<{ path?: string; roles?: string[] }> } }
>;
};
/**
* Replace each ffmpeg input's `path` with the unmasked value from
* `rawInputs` at the same index. Mirrors `_restore_masked_camera_paths`.
*/
function restoreFfmpegPaths(
ffmpeg: unknown,
rawInputs: Array<{ path?: string }> | undefined,
): unknown {
if (!ffmpeg || typeof ffmpeg !== "object" || Array.isArray(ffmpeg)) {
return ffmpeg;
}
const obj = cloneDeep(ffmpeg) as JsonObject;
const inputs = obj.inputs;
if (!Array.isArray(inputs) || !rawInputs) return obj;
inputs.forEach((input, i) => {
if (!input || typeof input !== "object" || Array.isArray(input)) return;
const rawPath = rawInputs[i]?.path;
if (typeof rawPath !== "string") return;
(input as JsonObject).path = rawPath;
});
return obj;
}
/**
* Replay the backend's per-camera detect-field formulas on the synthetic
* baseline so the source's computed values cancel out of the diff (the global
* config has no per-camera derivation).
*/
function applyDetectComputedDefaults(
detect: JsonObject,
fpsOverride?: number,
): JsonObject {
const result = { ...detect };
const fps =
typeof fpsOverride === "number"
? fpsOverride
: typeof result.fps === "number"
? result.fps
: 5;
if (result.min_initialized == null) {
result.min_initialized = Math.max(Math.floor(fps / 2), 2);
}
if (result.max_disappeared == null) {
result.max_disappeared = fps * 5;
}
const threshold = fps * 10;
const stationary = result.stationary;
const stat: JsonObject =
stationary && typeof stationary === "object" && !Array.isArray(stationary)
? { ...(stationary as JsonObject) }
: {};
if (stat.threshold == null) stat.threshold = threshold;
if (stat.interval == null) stat.interval = threshold;
result.stationary = stat as JsonValue;
return result;
}
/**
* Categories the dialog exposes. Most map 1:1 to a section config and flow
* through `prepareSectionSavePayload`. Special cases:
* - `motion_mask`/`object_masks`: carve-outs merged into the parent
* section's payload, or emitted standalone if the parent is unselected.
* - `ffmpeg_live`: new-camera target only.
* - `type`/`profiles`: not schema-driven; built directly below.
*/
export type CloneCategoryKey =
| "record"
| "snapshots"
| "review"
| "motion"
| "objects"
| "audio"
| "audio_transcription"
| "notifications"
| "birdseye"
| "mqtt"
| "timestamp_style"
| "onvif"
| "lpr"
| "face_recognition"
| "semantic_search"
| "genai"
| "type"
| "profiles"
| "detect"
| "zones"
| "motion_mask"
| "object_masks"
| "ffmpeg_live";
export type CloneCategoryGroup = "general" | "spatial" | "streams";
export type CloneCategory = {
key: CloneCategoryKey;
group: CloneCategoryGroup;
/** True when this category is only valid for "new camera" targets. */
newCameraOnly?: boolean;
/** True when this category is forced selected for new-camera targets. */
forcedForNewCamera?: boolean;
/** Default selection state for "existing camera" targets when resolutions match. */
defaultOnExisting: boolean;
};
export const CLONE_CATEGORIES: readonly CloneCategory[] = [
// General
{ key: "record", group: "general", defaultOnExisting: true },
{ key: "snapshots", group: "general", defaultOnExisting: true },
{ key: "review", group: "general", defaultOnExisting: true },
{ key: "motion", group: "general", defaultOnExisting: true },
{ key: "objects", group: "general", defaultOnExisting: true },
{ key: "audio", group: "general", defaultOnExisting: true },
{ key: "audio_transcription", group: "general", defaultOnExisting: true },
{ key: "notifications", group: "general", defaultOnExisting: true },
{ key: "birdseye", group: "general", defaultOnExisting: true },
{ key: "mqtt", group: "general", defaultOnExisting: true },
{ key: "timestamp_style", group: "general", defaultOnExisting: true },
{ key: "onvif", group: "general", defaultOnExisting: false },
{ key: "lpr", group: "general", defaultOnExisting: true },
{ key: "face_recognition", group: "general", defaultOnExisting: true },
{ key: "semantic_search", group: "general", defaultOnExisting: true },
{ key: "genai", group: "general", defaultOnExisting: true },
{ key: "type", group: "general", defaultOnExisting: false },
{ key: "profiles", group: "general", defaultOnExisting: true },
// Spatial — defaults computed via resolutionsMatch()
{ key: "detect", group: "spatial", defaultOnExisting: true },
{ key: "zones", group: "spatial", defaultOnExisting: true },
{ key: "motion_mask", group: "spatial", defaultOnExisting: true },
{ key: "object_masks", group: "spatial", defaultOnExisting: true },
// Streams — only for new-camera target, forced on
{
key: "ffmpeg_live",
group: "streams",
newCameraOnly: true,
forcedForNewCamera: true,
defaultOnExisting: false,
},
] as const;
/**
* Exact-match detect dimensions. Aspect-ratio tolerance isn't safe because
* zone/mask coords may be stored as explicit pixels, not just 0-1 relative.
*/
export function resolutionsMatch(
srcDetect: CameraConfig["detect"] | undefined,
dstDetect: CameraConfig["detect"] | undefined,
): boolean {
if (!srcDetect || !dstDetect) return false;
if (
typeof srcDetect.width !== "number" ||
typeof srcDetect.height !== "number"
) {
return false;
}
if (
typeof dstDetect.width !== "number" ||
typeof dstDetect.height !== "number"
) {
return false;
}
return (
srcDetect.width === dstDetect.width && srcDetect.height === dstDetect.height
);
}
/**
* Initial selection set. Existing-camera targets start empty copying onto
* a configured camera is destructive, so the user opts in explicitly.
* New-camera targets pre-select `defaultOnExisting` categories plus
* `forcedForNewCamera`.
*/
export function getCategoryDefaults(
targetIsNew: boolean,
): Set<CloneCategoryKey> {
const selected = new Set<CloneCategoryKey>();
if (!targetIsNew) return selected;
for (const cat of CLONE_CATEGORIES) {
if (cat.forcedForNewCamera || cat.defaultOnExisting) selected.add(cat.key);
}
return selected;
}
type BuildClonedPayloadsArgs = {
sourceCfg: CameraConfig;
sourceName: string;
/** Raw user input for new camera, or the existing-camera key. */
targetInput: string;
targetIsNew: boolean;
selectedKeys: Set<CloneCategoryKey>;
fullConfig: FrigateConfig;
fullSchema: RJSFSchema;
rawPaths?: RawCameraPaths;
};
/**
* Build the ordered payloads to PUT. Order: new-camera `…/add`, then
* `type` (LPR vs normal affects attribute resolution for later payloads),
* then per-section, then `profiles` (no hot-reload topic).
*/
export function buildClonedCameraPayloads({
sourceCfg,
sourceName,
targetInput,
targetIsNew,
selectedKeys,
fullConfig,
fullSchema,
rawPaths,
}: BuildClonedPayloadsArgs): SectionSavePayload[] {
const payloads: SectionSavePayload[] = [];
const { finalCameraName: target, friendlyName } = targetIsNew
? processCameraName(targetInput)
: { finalCameraName: targetInput, friendlyName: undefined };
// New-camera establishing payload (carries the `…/add` topic).
if (targetIsNew) {
const addOverrides: Record<string, unknown> = {
enabled: true,
};
if (friendlyName) {
addOverrides.friendly_name = friendlyName;
}
// Diff ffmpeg/live against the global config so fields matching
// inherited defaults drop out. Required fields (ffmpeg.inputs) come
// along because the source differs from global there.
if (selectedKeys.has("ffmpeg_live") && sourceCfg.ffmpeg) {
// /api/config masks `user:pass` as `*:*`; backend's restoration
// only handles existing cameras, so we unmask here for new ones.
const ffmpegWithRealPaths = restoreFfmpegPaths(
sourceCfg.ffmpeg,
rawPaths?.cameras?.[sourceName]?.ffmpeg?.inputs,
);
const diff = buildOverrides(
ffmpegWithRealPaths,
undefined,
fullConfig.ffmpeg,
);
const cleaned = cleanupFfmpegInputArgs(diff as JsonValue | undefined);
if (cleaned !== undefined) addOverrides.ffmpeg = cleaned;
}
if (selectedKeys.has("ffmpeg_live") && sourceCfg.live) {
const diff = buildOverrides(
sourceCfg.live,
undefined,
(fullConfig as unknown as JsonObject).live,
);
if (diff !== undefined) addOverrides.live = diff;
}
payloads.push({
basePath: `cameras.${target}`,
sanitizedOverrides: addOverrides as JsonObject,
updateTopic: `config/cameras/${target}/add`,
needsRestart: true,
pendingDataKey: `${target}::__add__`,
});
}
// Camera type — top-level scalar, no schema-driven section.
if (selectedKeys.has("type")) {
const srcType = (sourceCfg as { type?: string | null }).type;
if (srcType !== undefined && srcType !== null) {
payloads.push({
basePath: `cameras.${target}`,
sanitizedOverrides: { type: srcType },
updateTopic: undefined,
needsRestart: true,
pendingDataKey: `${target}::type`,
});
}
}
// Order matters for the existing-camera multi-PUT path (each PUT re-validates
// the whole config): `detect` then `zones` must precede sections that
// reference zones via `required_zones` (review, objects, snapshots, mqtt).
const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [
{ key: "detect", section: "detect" },
{ key: "zones", section: "zones" },
{ key: "motion", section: "motion" },
{ key: "objects", section: "objects" },
{ key: "record", section: "record" },
{ key: "snapshots", section: "snapshots" },
{ key: "review", section: "review" },
{ key: "audio", section: "audio" },
{ key: "audio_transcription", section: "audio_transcription" },
{ key: "notifications", section: "notifications" },
{ key: "birdseye", section: "birdseye" },
{ key: "mqtt", section: "mqtt" },
{ key: "timestamp_style", section: "timestamp_style" },
{ key: "onvif", section: "onvif" },
{ key: "lpr", section: "lpr" },
{ key: "face_recognition", section: "face_recognition" },
{ key: "semantic_search", section: "semantic_search" },
{ key: "genai", section: "genai" },
];
// Synthetic target reused as the diff baseline. New-camera: seed sections
// whose camera schema accepts all global fields (correct inheritance
// baseline), but leave divergent per-camera sections (mqtt, birdseye, lpr,
// face_recognition, semantic_search, audio_transcription, genai) unset —
// seeding from global would surface its extra fields as Reset markers.
const GLOBAL_INHERITED_SECTIONS = [
"detect",
"objects",
"motion",
"record",
"snapshots",
"review",
"audio",
"notifications",
"ffmpeg",
"live",
"timestamp_style",
];
const syntheticTargetCamera = targetIsNew
? ({
enabled: true,
...Object.fromEntries(
GLOBAL_INHERITED_SECTIONS.map((s) => [
s,
cloneDeep((fullConfig as unknown as JsonObject)[s]),
]).filter(([, value]) => value !== undefined && value !== null),
),
} as unknown as FrigateConfig["cameras"][string])
: ((fullConfig.cameras?.[target]
? cloneDeep(fullConfig.cameras[target])
: { enabled: true }) as unknown as FrigateConfig["cameras"][string]);
// Strip auto-default filters from the baseline (matching the per-section
// source strip) so default-only entries cancel. Includes `base_config` (the
// pre-profile parse getBaseCameraSectionValue reads) — otherwise its
// auto-populated entries become `""` resets and the backend KeyErrors
// deleting a key not in the YAML. Cloned above so this won't mutate the cache.
const syntheticCameraObj = syntheticTargetCamera as unknown as JsonObject;
const baseConfigObj = syntheticCameraObj.base_config as
| Record<string, JsonObject>
| undefined;
for (const section of Object.keys(FILTER_SECTION_DEFS)) {
const syntheticSection = syntheticCameraObj[section];
if (syntheticSection && typeof syntheticSection === "object") {
syntheticCameraObj[section] = stripAutoDefaultFilters(
section,
syntheticSection as JsonObject,
fullSchema,
fullConfig,
syntheticTargetCamera as CameraConfig,
);
}
const baseSection = baseConfigObj?.[section];
if (baseConfigObj && baseSection && typeof baseSection === "object") {
baseConfigObj[section] = stripAutoDefaultFilters(
section,
baseSection,
fullSchema,
fullConfig,
syntheticTargetCamera as CameraConfig,
);
}
}
// New-camera: synthetic's detect is from global (no per-camera derive),
// so apply the formulas using source's fps to keep both sides aligned.
// Existing-camera target already has the values from its own parse.
if (targetIsNew && sourceCfg.detect) {
const syntheticDetect = syntheticCameraObj.detect;
if (syntheticDetect && typeof syntheticDetect === "object") {
syntheticCameraObj.detect = applyDetectComputedDefaults(
syntheticDetect as JsonObject,
typeof sourceCfg.detect.fps === "number"
? sourceCfg.detect.fps
: undefined,
) as JsonValue;
}
}
const syntheticConfig: FrigateConfig = {
...fullConfig,
cameras: {
...fullConfig.cameras,
[target]: syntheticTargetCamera,
},
};
for (const { key, section } of SECTION_KEYS) {
if (!selectedKeys.has(key)) continue;
const sourceSectionValue = (
sourceCfg as unknown as Record<string, unknown>
)[section];
if (sourceSectionValue == null) continue;
// Sanitize the source like BaseSection's form does: strip runtime/derived
// and hidden-path fields (e.g. `hideAttributeFilters` drops untracked
// attributes based on the source's track list).
const sectionConfig = getSectionConfig(section, "camera");
const resolvedHiddenFields = resolveHiddenFieldEntries(
sectionConfig.hiddenFields,
{
fullConfig,
fullCameraConfig: sourceCfg,
level: "camera",
formData: sourceSectionValue as ConfigSectionData,
},
);
let pendingSectionValue: unknown = sanitizeSectionData(
cloneDeep(sourceSectionValue) as ConfigSectionData,
resolvedHiddenFields,
);
if (FILTER_SECTION_DEFS[section]) {
pendingSectionValue = stripAutoDefaultFilters(
section,
pendingSectionValue as JsonObject,
fullSchema,
fullConfig,
syntheticTargetCamera as CameraConfig,
);
}
// Re-inject masks the parent section's hiddenFields just stripped,
// when the mask category is also selected. `raw_mask` is never in
// the API response; `enabled_in_config` is runtime-only.
if (key === "motion" && selectedKeys.has("motion_mask")) {
const srcMask = (sourceSectionValue as { mask?: unknown }).mask;
if (srcMask !== undefined) {
pendingSectionValue = {
...(pendingSectionValue as object),
mask: stripDictEntryFields(srcMask, [
"enabled_in_config",
"raw_coordinates",
]),
};
}
}
if (key === "objects" && selectedKeys.has("object_masks")) {
const next = { ...(pendingSectionValue as JsonObject) };
// Camera-wide object mask (applies to all objects).
const srcMask = (sourceSectionValue as { mask?: unknown }).mask;
if (srcMask !== undefined) {
next.mask = stripDictEntryFields(srcMask, [
"enabled_in_config",
"raw_coordinates",
]) as JsonValue;
}
// Per-object masks (objects.filters.<label>.mask), stripped by the
// section's hiddenFields above. Merge them onto the reduced filters
// (creating the entry when the filter was otherwise all-default).
const filterMasks = extractFilterMasks(sourceSectionValue);
if (filterMasks) {
const mergedFilters: JsonObject = {
...((next.filters as JsonObject) ?? {}),
};
for (const [label, overlay] of Object.entries(filterMasks)) {
mergedFilters[label] = {
...((mergedFilters[label] as JsonObject) ?? {}),
...(overlay as JsonObject),
};
}
next.filters = mergedFilters;
}
pendingSectionValue = next;
}
// `color` is a Pydantic PrivateAttr (runtime-only).
if (key === "zones") {
pendingSectionValue = stripDictEntryFields(pendingSectionValue, [
"color",
]);
}
const payload = prepareSectionSavePayload({
pendingDataKey: `${target}::${section}`,
pendingData: pendingSectionValue,
config: syntheticConfig,
fullSchema,
});
if (payload) {
payloads.push(payload);
}
}
// Standalone mask payloads — only when the parent section isn't also
// selected (otherwise the masks were merged into its payload above).
if (selectedKeys.has("motion_mask") && !selectedKeys.has("motion")) {
const srcMask = (sourceCfg.motion as { mask?: unknown } | undefined)?.mask;
if (srcMask !== undefined) {
payloads.push({
basePath: `cameras.${target}.motion`,
sanitizedOverrides: {
mask: stripDictEntryFields(srcMask, [
"enabled_in_config",
"raw_coordinates",
]) as JsonValue,
},
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.motion}`,
needsRestart: false,
pendingDataKey: `${target}::motion.masks`,
});
}
}
if (selectedKeys.has("object_masks") && !selectedKeys.has("objects")) {
const overrides: JsonObject = {};
const srcMask = (sourceCfg.objects as { mask?: unknown } | undefined)?.mask;
if (srcMask !== undefined) {
overrides.mask = stripDictEntryFields(srcMask, [
"enabled_in_config",
"raw_coordinates",
]) as JsonValue;
}
const filterMasks = extractFilterMasks(sourceCfg.objects);
if (filterMasks) {
overrides.filters = filterMasks;
}
if (Object.keys(overrides).length > 0) {
payloads.push({
basePath: `cameras.${target}.objects`,
sanitizedOverrides: overrides,
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.objects}`,
needsRestart: false,
pendingDataKey: `${target}::objects.masks`,
});
}
}
// Profiles — wholesale dict replacement; no hot-reload topic.
if (selectedKeys.has("profiles")) {
const srcProfiles = (sourceCfg as { profiles?: unknown }).profiles;
if (srcProfiles && typeof srcProfiles === "object") {
payloads.push({
basePath: `cameras.${target}.profiles`,
sanitizedOverrides: cloneDeep(srcProfiles) as JsonObject,
updateTopic: undefined,
needsRestart: true,
pendingDataKey: `${target}::profiles`,
});
}
}
// New camera: scrub Reset markers (see stripResetMarkers), then bundle
// into one atomic `…/add` PUT so the backend validates the full camera
// at once (avoids per-section ordering issues).
if (targetIsNew) {
const scrubbed = payloads
.map((p) => {
const cleaned = stripResetMarkers(p.sanitizedOverrides as JsonValue);
return cleaned === undefined
? null
: { ...p, sanitizedOverrides: cleaned as JsonObject };
})
.filter((p): p is SectionSavePayload => p !== null);
return [bundleNewCameraPayload(scrubbed, target)];
}
return payloads;
}
/**
* Flatten payloads to `SaveAllPreviewItem`s with camera-relative
* `fieldPath`s (matches BaseSection's per-section preview).
*/
export function buildClonePreviewItems(
payloads: SectionSavePayload[],
targetCamera: string,
): SaveAllPreviewItem[] {
const cameraBase = `cameras.${targetCamera}`;
return payloads.flatMap((p) => {
const flattened = flattenOverrides(p.sanitizedOverrides as JsonValue);
const sectionRelativeBase =
p.basePath === cameraBase
? ""
: p.basePath.startsWith(`${cameraBase}.`)
? p.basePath.slice(cameraBase.length + 1)
: p.basePath;
return flattened.map(({ path, value }) => ({
scope: "camera" as const,
cameraName: targetCamera,
fieldPath: path
? sectionRelativeBase
? `${sectionRelativeBase}.${path}`
: path
: sectionRelativeBase,
value,
}));
});
}

View File

@ -81,6 +81,7 @@ export const cameraUpdateTopicMap: Record<string, string> = {
mqtt: "mqtt",
onvif: "onvif",
ui: "ui",
zones: "zones",
};
// Sections where global config serves as the default for per-camera config.

View File

@ -1,12 +1,18 @@
import Heading from "@/components/ui/heading";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
CONTROL_COLUMN_CLASS_NAME,
SettingsGroupCard,
SPLIT_ROW_CLASS_NAME,
} from "@/components/card/SettingsGroupCard";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
@ -15,6 +21,7 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
import {
LuCheck,
LuCopy,
LuExternalLink,
LuGripVertical,
LuPencil,
@ -22,6 +29,7 @@ import {
LuRefreshCcw,
LuTrash2,
} from "react-icons/lu";
import CloneCameraDialog from "@/components/settings/CloneCameraDialog";
import { Reorder, useDragControls } from "framer-motion";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
@ -50,6 +58,7 @@ import {
import type { ProfileState } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
import { isReplayCamera } from "@/utils/cameraUtil";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { cn } from "@/lib/utils";
import {
Select,
@ -88,6 +97,7 @@ export default function CameraManagementView({
const [showWizard, setShowWizard] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showCloneDialog, setShowCloneDialog] = useState(false);
// State for restart dialog when enabling a disabled camera
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
@ -217,12 +227,6 @@ export default function CameraManagementView({
return (
<>
<Toaster
richColors
className="z-[1000]"
position="top-center"
closeButton
/>
<div className="flex size-full space-y-6">
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
<Heading as="h4" className="mb-2">
@ -254,6 +258,27 @@ export default function CameraManagementView({
)}
</div>
{enabledCameras.length + disabledCameras.length > 0 && (
<div className="mb-5 space-y-3">
<div className="space-y-0.5">
<div className="text-md font-medium">
{t("cameraManagement.clone.sectionTitle")}
</div>
<p className="text-sm text-muted-foreground">
{t("cameraManagement.clone.sectionDescription")}
</p>
</div>
<Button
variant="select"
onClick={() => setShowCloneDialog(true)}
className="flex max-w-48 items-center gap-2"
>
<LuCopy className="h-4 w-4" />
{t("cameraManagement.clone.button")}
</Button>
</div>
)}
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
<SettingsGroupCard
title={
@ -364,6 +389,10 @@ export default function CameraManagementView({
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
<CloneCameraDialog
open={showCloneDialog}
onClose={() => setShowCloneDialog(false)}
/>
</>
);
}
@ -501,6 +530,7 @@ function CameraStatusSelect({
]);
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
const statusBar = useContext(StatusBarMessagesContext);
const [isSaving, setIsSaving] = useState(false);
const currentStatus: CameraStatus = isDisabledInConfig
@ -586,6 +616,12 @@ function CameraStatusSelect({
},
});
await onConfigChanged();
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter", { ns: "views/settings" }),
undefined,
"config_restart_required",
);
toast.success(
t("cameraManagement.streams.disableSuccess", {
ns: "views/settings",
@ -617,6 +653,7 @@ function CameraStatusSelect({
onConfigChanged,
sendEnabled,
setRestartDialogOpen,
statusBar,
t,
],
);