mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 06:11:54 +03:00
Compare commits
1 Commits
d28efce918
...
9e334686e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e334686e9 |
@ -88,18 +88,8 @@ 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" /> 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.
|
||||
|
||||
:::
|
||||
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`).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -272,7 +262,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. The Off state persists across Frigate restarts via a `.runtime_state.json` file alongside `config.yml` (see [Runtime toggle persistence](#runtime-toggle-persistence)).
|
||||
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. This state does **not** persist across Frigate restarts; the camera returns to On after a restart.
|
||||
- **Disabled** — the change is saved to your configuration file (`enabled: False`). The camera stops immediately, Frigate stops ffmpeg processes, and all live and historical UI elements for the camera are no longer visible but remains retained on disk. The camera is still listed in **Settings → Global configuration → Camera management** so it can be re-enabled. **A restart of Frigate is required to bring a disabled camera back to On.**
|
||||
|
||||
#### Turning a camera on or off
|
||||
@ -300,15 +290,6 @@ 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.
|
||||
|
||||
@ -130,8 +130,6 @@ 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`.
|
||||
|
||||
@ -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 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.
|
||||
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is **not** persisted across Frigate restarts — the camera returns to the configured state on restart. To permanently disable a camera, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
|
||||
|
||||
### `frigate/<camera_name>/enabled/state`
|
||||
|
||||
@ -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`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `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`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `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`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `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`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/snapshots/state`
|
||||
|
||||
|
||||
@ -908,11 +908,6 @@ 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
|
||||
|
||||
@ -280,7 +280,7 @@ async def create_face(request: Request, name: str):
|
||||
success response with details about the registration, or an error if face recognition
|
||||
is not enabled or the image cannot be processed.""",
|
||||
)
|
||||
def register_face(request: Request, name: str, file: UploadFile):
|
||||
async def register_face(request: Request, name: str, file: UploadFile):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@ -288,7 +288,7 @@ def register_face(request: Request, name: str, file: UploadFile):
|
||||
)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
result = None if context is None else context.register_face(name, file.file.read())
|
||||
result = None if context is None else context.register_face(name, await file.read())
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return JSONResponse(
|
||||
@ -313,7 +313,7 @@ def register_face(request: Request, name: str, file: UploadFile):
|
||||
registered faces in the system. Returns the recognized face name and confidence score,
|
||||
or an error if face recognition is not enabled or the image cannot be processed.""",
|
||||
)
|
||||
def recognize_face(request: Request, file: UploadFile):
|
||||
async def recognize_face(request: Request, file: UploadFile):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@ -321,7 +321,7 @@ def recognize_face(request: Request, file: UploadFile):
|
||||
)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
result = context.recognize_face(file.file.read())
|
||||
result = context.recognize_face(await file.read())
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return JSONResponse(
|
||||
|
||||
@ -348,11 +348,7 @@ class FrigateApp:
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
logger.info("Restoring persisted profile '%s'", 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
|
||||
)
|
||||
self.profile_manager.activate_profile(persisted)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
for name in self.config.cameras.keys():
|
||||
@ -616,9 +612,6 @@ 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:
|
||||
|
||||
@ -3,13 +3,11 @@
|
||||
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 (
|
||||
@ -69,7 +67,6 @@ 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,
|
||||
@ -400,60 +397,6 @@ 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
|
||||
@ -485,7 +428,6 @@ 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:
|
||||
@ -510,7 +452,6 @@ 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:
|
||||
@ -673,7 +614,6 @@ 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:
|
||||
@ -730,7 +670,6 @@ 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:
|
||||
@ -750,7 +689,6 @@ 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:
|
||||
|
||||
@ -1,163 +0,0 @@
|
||||
"""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)
|
||||
@ -124,24 +124,11 @@ class ProfileManager:
|
||||
self.config.active_profile = None
|
||||
self._persist_active_profile(None)
|
||||
|
||||
# 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]:
|
||||
def activate_profile(self, profile_name: Optional[str]) -> 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.
|
||||
@ -169,11 +156,6 @@ 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",
|
||||
|
||||
@ -94,21 +94,9 @@ class AudioProcessor(FrigateProcess):
|
||||
self.camera_metrics = camera_metrics
|
||||
self.config = config
|
||||
|
||||
def __stop_audio_thread(self, camera: str) -> None:
|
||||
thread = self.audio_threads.pop(camera, None)
|
||||
if thread is None:
|
||||
return
|
||||
|
||||
thread.stop()
|
||||
thread.join(10)
|
||||
if thread.is_alive():
|
||||
self.logger.warning(f"Audio maintainer thread for {camera} is still alive")
|
||||
else:
|
||||
self.logger.info(f"Audio maintainer stopped for {camera}")
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.config.logger)
|
||||
self.audio_threads: dict[str, AudioEventMaintainer] = {}
|
||||
audio_threads: dict[str, AudioEventMaintainer] = {}
|
||||
|
||||
threading.current_thread().name = "process:audio_manager"
|
||||
|
||||
@ -132,13 +120,12 @@ class AudioProcessor(FrigateProcess):
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.audio,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
],
|
||||
)
|
||||
|
||||
def spawn_if_needed(camera: CameraConfig) -> None:
|
||||
name = camera.name
|
||||
if name is None or name in self.audio_threads:
|
||||
if name is None or name in audio_threads:
|
||||
return
|
||||
if not camera.enabled or not camera.audio.enabled:
|
||||
return
|
||||
@ -152,7 +139,7 @@ class AudioProcessor(FrigateProcess):
|
||||
self.transcription_model_runner,
|
||||
self.stop_event, # type: ignore[arg-type]
|
||||
)
|
||||
self.audio_threads[name] = thread
|
||||
audio_threads[name] = thread
|
||||
thread.start()
|
||||
self.logger.info(f"Audio maintainer started for {name}")
|
||||
|
||||
@ -161,31 +148,21 @@ class AudioProcessor(FrigateProcess):
|
||||
|
||||
self.logger.info(f"Audio processor started (pid: {self.pid})")
|
||||
|
||||
# poll for newly added/removed cameras or cameras flipped to
|
||||
# audio.enabled at runtime
|
||||
# poll for newly added cameras or cameras flipped to audio.enabled at runtime
|
||||
while not self.stop_event.wait(timeout=1.0):
|
||||
updated_topics = config_subscriber.check_for_updates()
|
||||
|
||||
# stop maintainers for removed cameras so their ffmpeg process is
|
||||
# torn down and they stop touching camera_metrics (which the camera
|
||||
# maintainer has already popped for the removed camera)
|
||||
for removed_camera in updated_topics.get(
|
||||
CameraConfigUpdateEnum.remove.name, []
|
||||
):
|
||||
self.__stop_audio_thread(removed_camera)
|
||||
|
||||
config_subscriber.check_for_updates()
|
||||
for camera in self.config.cameras.values():
|
||||
spawn_if_needed(camera)
|
||||
|
||||
config_subscriber.stop()
|
||||
|
||||
for thread in self.audio_threads.values():
|
||||
for thread in audio_threads.values():
|
||||
thread.join(1)
|
||||
if thread.is_alive():
|
||||
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
|
||||
thread.join(10)
|
||||
|
||||
for thread in self.audio_threads.values():
|
||||
for thread in audio_threads.values():
|
||||
if thread.is_alive():
|
||||
self.logger.warning(f"Thread {thread.name} is still alive")
|
||||
|
||||
@ -207,9 +184,6 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.camera_config = camera
|
||||
self.camera_metrics = camera_metrics
|
||||
self.stop_event = stop_event
|
||||
# per-camera stop signal so a single maintainer can be torn down at
|
||||
# runtime (e.g. on camera removal) without stopping the whole process
|
||||
self.camera_stop_event = threading.Event()
|
||||
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
|
||||
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
|
||||
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
|
||||
@ -259,11 +233,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.was_audio_enabled = camera.audio.enabled
|
||||
|
||||
def detect_audio(self, audio: np.ndarray) -> None:
|
||||
if (
|
||||
not self.camera_config.audio.enabled
|
||||
or self.stop_event.is_set()
|
||||
or self.camera_stop_event.is_set()
|
||||
):
|
||||
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
||||
return
|
||||
|
||||
audio_as_float: np.ndarray = audio.astype(np.float32)
|
||||
@ -382,15 +352,11 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
|
||||
log_and_restart()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal this maintainer to exit its run loop and clean up."""
|
||||
self.camera_stop_event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
if self.camera_config.enabled:
|
||||
self.start_or_restart_ffmpeg()
|
||||
|
||||
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
|
||||
while not self.stop_event.is_set():
|
||||
# check if there is an updated config
|
||||
self.config_subscriber.check_for_updates()
|
||||
|
||||
|
||||
@ -1,217 +0,0 @@
|
||||
"""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()
|
||||
@ -727,55 +727,6 @@ 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."""
|
||||
|
||||
@ -1,136 +0,0 @@
|
||||
"""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()
|
||||
@ -1,181 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@ -544,92 +544,6 @@
|
||||
"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": {
|
||||
@ -1491,17 +1405,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@ -107,7 +107,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
<FormLabel>{t("form.user")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
@ -125,7 +125,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
<FormLabel>{t("form.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
|
||||
@ -257,7 +257,7 @@ export function ExportCard({
|
||||
{editName && (
|
||||
<>
|
||||
<Input
|
||||
className="mt-3"
|
||||
className="text-md mt-3"
|
||||
type="search"
|
||||
placeholder={editName?.original}
|
||||
value={
|
||||
@ -275,6 +275,7 @@ export function ExportCard({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("editExport.saveExport")}
|
||||
size="sm"
|
||||
variant="select"
|
||||
disabled={(editName?.update?.length ?? 0) == 0}
|
||||
onClick={() => submitRename()}
|
||||
|
||||
@ -14,7 +14,7 @@ type SettingsGroupCardProps = {
|
||||
export function SettingsGroupCard({ title, children }: SettingsGroupCardProps) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
|
||||
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@ -48,7 +48,7 @@ export default function ChatSettings({
|
||||
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("settings.show_stats.title")}</div>
|
||||
<div className="text-md">{t("settings.show_stats.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.show_stats.desc")}
|
||||
</div>
|
||||
@ -77,7 +77,7 @@ export default function ChatSettings({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-scroll" className="cursor-pointer">
|
||||
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
|
||||
{t("settings.auto_scroll.title")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@ -485,7 +485,7 @@ export default function ClassificationModelEditDialog({
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"wizard.step1.classPlaceholder",
|
||||
)}
|
||||
|
||||
@ -214,7 +214,7 @@ export default function Step1NameAndDefine({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder={t("wizard.step1.namePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -457,7 +457,7 @@ export default function Step1NameAndDefine({
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder={t("wizard.step1.classPlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -489,7 +489,7 @@ export default function Step1NameAndDefine({
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -458,7 +458,7 @@ export default function Step2StateArea({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
@ -540,7 +540,7 @@ export default function Step3ChooseExamples({
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={doRefresh}
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
@ -693,7 +693,7 @@ export default function Step3ChooseExamples({
|
||||
)}
|
||||
|
||||
{!isTraining && (
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -4,26 +4,17 @@ const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["streams", "height", "quality"],
|
||||
fieldOrder: ["stream_name", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality"],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["streams", "height", "quality"],
|
||||
restartRequired: ["stream_name", "height", "quality"],
|
||||
hiddenFields: ["streams"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["height", "quality"],
|
||||
uiSchema: {
|
||||
streams: {
|
||||
"ui:field": "LiveStreamsField",
|
||||
"ui:options": {
|
||||
label: false,
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@ -490,6 +491,7 @@ export default function NotificationsSettingsExtras({
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div className={cn("w-full max-w-5xl space-y-6")}>
|
||||
{isAdmin && (
|
||||
@ -519,7 +521,7 @@ export default function NotificationsSettingsExtras({
|
||||
<FormControl>
|
||||
<Input
|
||||
id="notification-email"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
||||
placeholder={t(
|
||||
"notification.email.placeholder",
|
||||
)}
|
||||
@ -786,7 +788,7 @@ export function CameraNotificationSwitch({
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<CameraNameLabel
|
||||
className="cursor-pointer text-primary smart-capitalize"
|
||||
className="text-md cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor="camera"
|
||||
camera={camera}
|
||||
/>
|
||||
|
||||
@ -32,7 +32,7 @@ import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
|
||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import get from "lodash/get";
|
||||
@ -1236,7 +1236,7 @@ export function ConfigSection({
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
onDeleteProfileSection?.();
|
||||
setIsDeleteProfileDialogOpen(false);
|
||||
|
||||
@ -1,346 +0,0 @@
|
||||
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;
|
||||
@ -2,4 +2,3 @@
|
||||
export { LayoutGridField } from "./LayoutGridField";
|
||||
export { DetectorHardwareField } from "./DetectorHardwareField";
|
||||
export { ReplaceRulesField } from "./ReplaceRulesField";
|
||||
export { LiveStreamsField } from "./LiveStreamsField";
|
||||
|
||||
@ -51,7 +51,6 @@ 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;
|
||||
@ -110,6 +109,5 @@ export const frigateTheme: FrigateTheme = {
|
||||
CameraInputsField: CameraInputsField,
|
||||
DictAsYamlField: DictAsYamlField,
|
||||
KnownPlatesField: KnownPlatesField,
|
||||
LiveStreamsField: LiveStreamsField,
|
||||
},
|
||||
};
|
||||
|
||||
@ -371,7 +371,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
key={group.groupKey}
|
||||
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
|
||||
>
|
||||
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
|
||||
@ -79,7 +79,7 @@ export function ArrayAsTextWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn(fieldClassName)}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
value={text}
|
||||
disabled={disabled || readonly}
|
||||
rows={(options.rows as number) || 3}
|
||||
|
||||
@ -124,7 +124,7 @@ export function CameraPathWidget(props: WidgetProps) {
|
||||
<div className={cn("relative", fieldClassName)}>
|
||||
<Input
|
||||
id={id}
|
||||
className={cn(canToggle ? "pr-10" : undefined)}
|
||||
className={cn("text-md", canToggle ? "pr-10" : undefined)}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
disabled={disabled || readonly}
|
||||
|
||||
@ -26,7 +26,7 @@ export function TextWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
className={cn(fieldClassName)}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
|
||||
@ -26,7 +26,7 @@ export function TextareaWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn(fieldClassName)}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
@ -848,7 +847,7 @@ export function CameraGroupEdit({
|
||||
<FormLabel>{t("group.name.label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder={t("group.name.placeholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -974,9 +973,10 @@ export function CameraGroupEdit({
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<DialogFooter className="py-5 md:pb-0">
|
||||
<div className="flex flex-row gap-2 py-5 md:pb-0">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
>
|
||||
@ -985,6 +985,7 @@ export function CameraGroupEdit({
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
>
|
||||
@ -997,7 +998,7 @@ export function CameraGroupEdit({
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@ -40,7 +40,7 @@ export function LogSettingsButton({
|
||||
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("filter")}</div>
|
||||
<div className="text-md">{t("filter")}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("logSettings.filterBySeverity")}
|
||||
</div>
|
||||
@ -53,7 +53,7 @@ export function LogSettingsButton({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("logSettings.loading.title")}</div>
|
||||
<div className="text-md">{t("logSettings.loading.title")}</div>
|
||||
<div className="mt-2.5 flex flex-col gap-2.5">
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("logSettings.loading.desc")}
|
||||
|
||||
@ -56,25 +56,18 @@ export function CameraLineGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.round(val as number)];
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.round(val as number)],
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
},
|
||||
[config?.ui.timezone, format, locale],
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
@ -218,25 +211,18 @@ export function EventsPerSecondsLineGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.round(val as number) - 1];
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.round(val as number) - 1],
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
},
|
||||
[config?.ui.timezone, format, locale],
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
||||
@ -61,11 +61,6 @@ export function ThresholdBarGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
const dateIndex = Math.round(val as number);
|
||||
@ -74,18 +69,16 @@ export function ThresholdBarGraph({
|
||||
if (dateIndex < 0) {
|
||||
timeOffset = 5 * Math.abs(dateIndex);
|
||||
}
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.max(1, dateIndex) - 1] - timeOffset;
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
},
|
||||
[config?.ui.timezone, format, locale],
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
||||
@ -119,7 +119,7 @@ export default function IconPicker({
|
||||
placeholder={t("iconPicker.search.placeholder", {
|
||||
ns: "components/icons",
|
||||
})}
|
||||
className="mb-3 md:text-sm"
|
||||
className="text-md mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@ -696,7 +696,7 @@ export default function InputWithTags({
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
className="h-9 pr-32"
|
||||
className="text-md h-9 pr-32"
|
||||
placeholder={t("placeholder.search")}
|
||||
/>
|
||||
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
|
||||
|
||||
@ -112,7 +112,11 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder={placeholderName} {...field} />
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={placeholderName}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{nameDescription && (
|
||||
<FormDescription>{nameDescription}</FormDescription>
|
||||
@ -130,6 +134,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={placeholderId}
|
||||
disabled={idDisabled}
|
||||
{...field}
|
||||
|
||||
@ -69,6 +69,7 @@ export function SaveSearchDialog({
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={searchName}
|
||||
className="text-md"
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
placeholder={t("search.saveSearch.placeholder")}
|
||||
/>
|
||||
@ -87,6 +88,7 @@ export function SaveSearchDialog({
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="select"
|
||||
className="mb-2 md:mb-0"
|
||||
aria-label={t("search.saveSearch.button.save.label")}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
|
||||
@ -77,7 +77,7 @@ export default function TextEntry({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
className="text-md w-full"
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
@ -276,7 +276,7 @@ export default function LiveContextMenu({
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<div className="flex flex-col items-start gap-1 py-1 pl-2">
|
||||
<div className="text-primary-variant smart-capitalize">
|
||||
<div className="text-md text-primary-variant smart-capitalize">
|
||||
<CameraNameLabel camera={camera} />
|
||||
</div>
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
|
||||
@ -213,30 +213,36 @@ export default function CreateRoleDialog({
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -433,30 +433,36 @@ export default function CreateTriggerDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -411,30 +411,36 @@ export default function CreateUserDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -144,7 +144,7 @@ export function CustomTimeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={startClock}
|
||||
@ -210,7 +210,7 @@ export function CustomTimeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={endClock}
|
||||
|
||||
@ -113,14 +113,19 @@ export function DebugReplayContent({
|
||||
|
||||
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
||||
|
||||
<DialogFooter className="mt-3 sm:mt-0">
|
||||
<DialogFooter
|
||||
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
|
||||
>
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
variant="select"
|
||||
disabled={isStarting}
|
||||
onClick={() => {
|
||||
|
||||
@ -70,31 +70,38 @@ export default function DeleteRoleDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
variant="destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("roles.dialog.deleteRole.deleting")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
variant="destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("roles.dialog.deleteRole.deleting")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -43,30 +43,36 @@ export default function DeleteTriggerDialog({
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
onClick={onDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={onDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -46,21 +46,27 @@ export default function DeleteUserDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -105,15 +105,13 @@ export default function EditRoleCamerasDialog({
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-5 pt-2"
|
||||
className="space-y-5 pt-4"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
|
||||
<FormDescription className="text-xs text-muted-foreground">
|
||||
{t("roles.dialog.form.cameras.desc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
|
||||
<FormDescription className="text-xs text-muted-foreground">
|
||||
{t("roles.dialog.form.cameras.desc")}
|
||||
</FormDescription>
|
||||
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
|
||||
{cameras.map((camera) => (
|
||||
<FormField
|
||||
@ -161,30 +159,36 @@ export default function EditRoleCamerasDialog({
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -287,7 +287,7 @@ export default function ExportDialog({
|
||||
<Content
|
||||
className={
|
||||
isDesktop
|
||||
? "scrollbar-container max-h-[90dvh] overflow-y-auto sm:rounded-lg md:rounded-2xl"
|
||||
? "sm:rounded-lg md:rounded-2xl"
|
||||
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
@ -794,6 +794,7 @@ export function ExportContent({
|
||||
)}
|
||||
|
||||
<Input
|
||||
className="text-md"
|
||||
type="search"
|
||||
placeholder={t("export.name.placeholder")}
|
||||
value={name}
|
||||
@ -834,11 +835,13 @@ export function ExportContent({
|
||||
{selectedCaseId === "new" && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||
value={singleNewCaseName}
|
||||
onChange={(e) => setSingleNewCaseName(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||
value={singleNewCaseDescription}
|
||||
onChange={(e) =>
|
||||
@ -985,6 +988,7 @@ export function ExportContent({
|
||||
{t("export.multiCamera.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
className="text-md"
|
||||
type="search"
|
||||
placeholder={t("export.multiCamera.namePlaceholder")}
|
||||
value={name}
|
||||
@ -1024,11 +1028,13 @@ export function ExportContent({
|
||||
{batchCaseSelection === "new" && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||
value={newCaseName}
|
||||
onChange={(event) => setNewCaseName(event.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||
value={newCaseDescription}
|
||||
onChange={(event) =>
|
||||
@ -1043,15 +1049,20 @@ export function ExportContent({
|
||||
</Tabs>
|
||||
|
||||
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
||||
<DialogFooter className="mt-3 sm:mt-0">
|
||||
<DialogFooter
|
||||
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
|
||||
>
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
{activeTab === "export" ? (
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("export.selectOrExport")}
|
||||
variant="select"
|
||||
disabled={isStartingExport}
|
||||
@ -1075,10 +1086,12 @@ export function ExportContent({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("export.multiCamera.exportButton", {
|
||||
count: selectedCameraCount,
|
||||
})}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={!canStartBatchExport}
|
||||
onClick={() => void startBatchExport()}
|
||||
>
|
||||
|
||||
@ -85,7 +85,7 @@ export default function ImagePicker({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("imagePicker.search.placeholder")}
|
||||
className="mb-3 md:text-sm"
|
||||
className="text-md mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
|
||||
@ -290,6 +290,7 @@ export default function MultiExportDialog({
|
||||
const newCaseInputs = (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||
value={newCaseName}
|
||||
onChange={(event) => setNewCaseName(event.target.value)}
|
||||
@ -297,6 +298,7 @@ export default function MultiExportDialog({
|
||||
autoFocus={isDesktop}
|
||||
/>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||
value={newCaseDescription}
|
||||
onChange={(event) => setNewCaseDescription(event.target.value)}
|
||||
@ -342,7 +344,11 @@ export default function MultiExportDialog({
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button onClick={() => handleOpenChange(false)} disabled={isExporting}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
@ -374,7 +380,7 @@ export default function MultiExportDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
<DialogFooter>{footer}</DialogFooter>
|
||||
<DialogFooter className="gap-2">{footer}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@ -393,7 +399,7 @@ export default function MultiExportDialog({
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
<DialogFooter className="mt-4">{footer}</DialogFooter>
|
||||
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@ -117,23 +117,30 @@ export default function RoleChangeDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
onClick={() => onSave(selectedRole)}
|
||||
type="button"
|
||||
disabled={selectedRole === currentRole}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
onClick={() => onSave(selectedRole)}
|
||||
type="button"
|
||||
disabled={selectedRole === currentRole}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -450,30 +450,36 @@ export default function SetPasswordDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -196,16 +196,21 @@ export function ShareTimestampContent({
|
||||
|
||||
{isDesktop && <Separator className="my-4 bg-secondary" />}
|
||||
|
||||
<DialogFooter className="mt-3 sm:mt-0">
|
||||
<DialogFooter
|
||||
className={cn("mt-4", !isDesktop && "flex flex-col-reverse gap-2")}
|
||||
>
|
||||
{onCancel && (
|
||||
<Button
|
||||
className={cn(!isDesktop && "w-full")}
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={cn(!isDesktop && "w-full")}
|
||||
variant="select"
|
||||
onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))}
|
||||
>
|
||||
@ -333,7 +338,7 @@ function CustomTimestampSelector({
|
||||
/>
|
||||
<div className="my-3 h-px w-full bg-secondary" />
|
||||
<input
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="shareTimestamp"
|
||||
type="time"
|
||||
value={clock}
|
||||
|
||||
@ -145,7 +145,7 @@ export function AnnotationSettingsPane({
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-2">
|
||||
<div className="text-md mb-2">
|
||||
{t("trackingDetails.annotationSettings.title")}
|
||||
</div>
|
||||
|
||||
|
||||
@ -131,7 +131,7 @@ export default function CreateFaceWizardDialog({
|
||||
forbiddenPattern={/#/}
|
||||
forbiddenErrorMessage={t("description.nameCannotContainHash")}
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex justify-end py-2">
|
||||
<Button variant="select" type="submit">
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
@ -144,7 +144,7 @@ export default function CreateFaceWizardDialog({
|
||||
{t("steps.description.uploadFace", { name })}
|
||||
</div>
|
||||
<ImageEntry onSave={onUploadImage}>
|
||||
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex justify-end py-2">
|
||||
<Button variant="select" type="submit">
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
@ -173,7 +173,7 @@ export default function CreateFaceWizardDialog({
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
|
||||
@ -22,7 +22,6 @@ type SaveAllPreviewPopoverProps = {
|
||||
className?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
disablePortal?: boolean;
|
||||
};
|
||||
|
||||
export default function SaveAllPreviewPopover({
|
||||
@ -30,7 +29,6 @@ export default function SaveAllPreviewPopover({
|
||||
className,
|
||||
align = "end",
|
||||
side = "bottom",
|
||||
disablePortal = false,
|
||||
}: SaveAllPreviewPopoverProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@ -69,7 +67,6 @@ 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()}
|
||||
>
|
||||
@ -111,13 +108,13 @@ export default function SaveAllPreviewPopover({
|
||||
}`}
|
||||
className="rounded-md border border-secondary bg-background_alt p-2"
|
||||
>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("saveAllPreview.scope.label", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{scopeLabel}</span>
|
||||
<span className="truncate">{scopeLabel}</span>
|
||||
{item.profileName && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
@ -125,7 +122,7 @@ export default function SaveAllPreviewPopover({
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
<span className="truncate font-medium">
|
||||
{item.profileName}
|
||||
</span>
|
||||
</>
|
||||
@ -135,7 +132,7 @@ export default function SaveAllPreviewPopover({
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<span className="min-w-0 break-all font-mono">
|
||||
<span className="break-all font-mono">
|
||||
{item.fieldPath}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
@ -143,7 +140,7 @@ export default function SaveAllPreviewPopover({
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<span className="min-w-0 whitespace-pre-wrap break-all font-mono">
|
||||
<span className="whitespace-pre-wrap break-words font-mono">
|
||||
{formatValue(item.value)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1569,7 +1569,7 @@ function ObjectDetailsTab({
|
||||
{t("button.yes", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
className="flex-1 text-white"
|
||||
aria-label={t("button.no", { ns: "common" })}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
@ -1706,7 +1706,7 @@ function ObjectDetailsTab({
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
className="h-32 md:text-sm"
|
||||
className="text-md h-32 md:text-sm"
|
||||
placeholder={t("details.description.placeholder")}
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
|
||||
@ -821,7 +821,7 @@ export function TrackingDetails({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="capitalize">{label}</span>
|
||||
<div className="flex items-center text-xs text-secondary-foreground">
|
||||
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
|
||||
{formattedStart ?? ""}
|
||||
{event.end_time != null ? (
|
||||
<> - {formattedEnd}</>
|
||||
@ -1072,7 +1072,7 @@ function LifecycleIconRow({
|
||||
|
||||
<div className="ml-2 flex w-full min-w-0 flex-1">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start break-words text-left">
|
||||
<div className="text-md flex items-start break-words text-left">
|
||||
{getLifecycleItemDescription(item)}
|
||||
</div>
|
||||
{/* Only show Score/Ratio/Area for object events, not for audio (heard) or manual API (external) events */}
|
||||
|
||||
@ -121,22 +121,28 @@ export default function DeleteCameraDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
onClick={handleDelete}
|
||||
disabled={!selectedCamera}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={handleDelete}
|
||||
disabled={!selectedCamera}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
@ -167,31 +173,39 @@ export default function DeleteCameraDialog({
|
||||
{t("cameraManagement.deleteCameraDialog.deleteExports")}
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.back", { ns: "common" })}
|
||||
onClick={handleBack}
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>
|
||||
{t("cameraManagement.deleteCameraDialog.confirmButton")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
t("cameraManagement.deleteCameraDialog.confirmButton")
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.back", { ns: "common" })}
|
||||
onClick={handleBack}
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex flex-1 text-white"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>
|
||||
{t(
|
||||
"cameraManagement.deleteCameraDialog.confirmButton",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
t("cameraManagement.deleteCameraDialog.confirmButton")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -173,7 +173,7 @@ export function FrigatePlusDialog({
|
||||
{t("button.yes", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
className="flex-1 text-white"
|
||||
aria-label={t("button.no", { ns: "common" })}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
|
||||
@ -7,7 +7,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
|
||||
@ -75,7 +77,7 @@ export default function MultiSelectDialog({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className="pt-4">
|
||||
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
{t("button.cancel")}
|
||||
</Button>
|
||||
|
||||
@ -144,13 +144,18 @@ export default function OptionAndInputDialog({
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{nameLabel}
|
||||
</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
className="text-md"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
rows={2}
|
||||
@ -159,9 +164,10 @@ export default function OptionAndInputDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
||||
@ -349,7 +349,7 @@ function TimeRangeFilterContent({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-row items-center justify-center">
|
||||
<input
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={selectedAfterHour}
|
||||
@ -389,7 +389,7 @@ function TimeRangeFilterContent({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-center">
|
||||
<input
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TextEntryDialogProps = {
|
||||
@ -61,7 +63,7 @@ export default function TextEntryDialog({
|
||||
forbiddenPattern={forbiddenPattern}
|
||||
forbiddenErrorMessage={forbiddenErrorMessage}
|
||||
>
|
||||
<DialogFooter>
|
||||
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
|
||||
@ -443,7 +443,7 @@ export default function LivePlayer({
|
||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||
<div>{t("streamOffline.title")}</div>
|
||||
<div className="text-md">{t("streamOffline.title")}</div>
|
||||
<TbExclamationCircle className="size-6" />
|
||||
{!isCompact && (
|
||||
<p className="text-center text-sm">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "../ui/sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
@ -326,6 +327,7 @@ export default function MotionMaskEditPane({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Heading as="h3" className="my-2">
|
||||
{polygon.name.length
|
||||
? t("masksAndZones.motionMasks.edit")
|
||||
|
||||
@ -31,6 +31,7 @@ import { FaCheckCircle } from "react-icons/fa";
|
||||
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "../ui/sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
@ -334,6 +335,7 @@ export default function ObjectMaskEditPane({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Heading as="h3" className="my-2">
|
||||
{polygon.name.length
|
||||
? t("masksAndZones.objectMasks.edit")
|
||||
|
||||
@ -24,12 +24,12 @@ import { toRGBColorString } from "@/utils/canvasUtil";
|
||||
import { Polygon, PolygonType } from "@/types/canvas";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
||||
import IconWrapper from "../ui/icon-wrapper";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -368,6 +368,8 @@ export default function PolygonItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
|
||||
<div
|
||||
key={index}
|
||||
className="transition-background relative my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100"
|
||||
@ -509,7 +511,7 @@ export default function PolygonItem({
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{polygon.polygonSource === "override"
|
||||
|
||||
@ -59,7 +59,9 @@ export default function ExploreSettings({
|
||||
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("explore.settings.defaultView.title")}</div>
|
||||
<div className="text-md">
|
||||
{t("explore.settings.defaultView.title")}
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("explore.settings.defaultView.desc")}
|
||||
</div>
|
||||
@ -95,7 +97,9 @@ export default function ExploreSettings({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex w-full flex-col space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("explore.settings.gridColumns.title")}</div>
|
||||
<div className="text-md">
|
||||
{t("explore.settings.gridColumns.title")}
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("explore.settings.gridColumns.desc")}
|
||||
</div>
|
||||
@ -158,7 +162,9 @@ export function SearchTypeContent({
|
||||
<div className="overflow-x-hidden">
|
||||
<DropdownMenuSeparator className="mb-3" />
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("explore.settings.searchSource.label")}</div>
|
||||
<div className="text-md">
|
||||
{t("explore.settings.searchSource.label")}
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("explore.settings.searchSource.desc")}
|
||||
</div>
|
||||
|
||||
@ -24,6 +24,7 @@ import { Label } from "../ui/label";
|
||||
import PolygonEditControls from "./PolygonEditControls";
|
||||
import { FaCheckCircle } from "react-icons/fa";
|
||||
import axios from "axios";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
@ -627,6 +628,7 @@ export default function ZoneEditPane({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Heading as="h3" className="my-2">
|
||||
{polygon.name.length
|
||||
? t("masksAndZones.zones.edit")
|
||||
@ -707,7 +709,7 @@ export default function ZoneEditPane({
|
||||
<FormLabel>{t("masksAndZones.zones.inertia.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder="3"
|
||||
{...field}
|
||||
/>
|
||||
@ -732,7 +734,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder="0"
|
||||
{...field}
|
||||
/>
|
||||
@ -862,7 +864,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
onFocus={() => setActiveLine(1)}
|
||||
onBlur={() => setActiveLine(undefined)}
|
||||
@ -889,7 +891,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
onFocus={() => setActiveLine(2)}
|
||||
onBlur={() => setActiveLine(undefined)}
|
||||
@ -916,7 +918,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
onFocus={() => setActiveLine(3)}
|
||||
onBlur={() => setActiveLine(undefined)}
|
||||
@ -943,7 +945,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
onFocus={() => setActiveLine(4)}
|
||||
onBlur={() => setActiveLine(undefined)}
|
||||
@ -970,7 +972,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -171,7 +171,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -192,7 +192,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
@ -212,7 +212,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -233,7 +233,7 @@ export default function Step1NameCamera({
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="h-8 pr-10"
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
@ -316,7 +316,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder="80"
|
||||
@ -440,7 +440,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder="rtsp://username:password@host:port/path"
|
||||
{...field}
|
||||
/>
|
||||
@ -455,7 +455,7 @@ export default function Step1NameCamera({
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -626,7 +626,7 @@ function ProbeFooterButtons({
|
||||
<ActivityIndicator className="size-4" />
|
||||
{t("cameraWizard.step2.probing")}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
@ -649,7 +649,7 @@ function ProbeFooterButtons({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-destructive">{probeError}</div>
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
@ -670,7 +670,7 @@ function ProbeFooterButtons({
|
||||
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
|
||||
if (mode === "manual") {
|
||||
return (
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
@ -707,7 +707,7 @@ function ProbeFooterButtons({
|
||||
|
||||
// Default probe footer
|
||||
return (
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -731,7 +731,7 @@ export default function Step3StreamConfig({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
|
||||
@ -490,7 +490,7 @@ export default function Step4Validation({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
|
||||
@ -176,15 +176,20 @@ export default function Step1NameAndType({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="select"
|
||||
disabled={!form.formState.isValid}
|
||||
className="sm:flex-1"
|
||||
className="flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -109,15 +109,20 @@ export default function Step2ConfigureData({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="select"
|
||||
disabled={!form.formState.isValid}
|
||||
className="sm:flex-1"
|
||||
className="flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -181,15 +181,20 @@ export default function Step3ThresholdAndActions({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
className="sm:flex-1"
|
||||
className="flex-1"
|
||||
variant="select"
|
||||
>
|
||||
{isLoading && <ActivityIndicator className="mr-2 size-5" />}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
@ -61,35 +59,15 @@ const AlertDialogHeader = ({
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const alertDialogFooterVariants = cva(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
|
||||
// [&>button] only targets real button children, so non-button siblings are untouched.
|
||||
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
|
||||
// context content (text/popover) alongside actions: space-between on desktop.
|
||||
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
|
||||
split: "flex-col sm:items-center sm:justify-between",
|
||||
// alignment only; never touches children. Escape hatch for unusual content.
|
||||
plain: "sm:justify-end",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "actions",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof alertDialogFooterVariants>) => (
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(alertDialogFooterVariants({ variant }), className)}
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -11,7 +11,8 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: "bg-secondary text-primary hover:bg-secondary/80",
|
||||
select: "bg-selected text-selected-foreground hover:bg-opacity-90",
|
||||
destructive: "bg-destructive text-white hover:bg-destructive/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useHistoryBack } from "@/hooks/use-history-back";
|
||||
@ -108,32 +107,15 @@ const DialogHeader = ({
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const dialogFooterVariants = cva("flex flex-col-reverse gap-2 sm:flex-row", {
|
||||
variants: {
|
||||
variant: {
|
||||
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
|
||||
// [&>button] only targets real button children, so non-button siblings are untouched.
|
||||
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
|
||||
// context content (text/popover) alongside actions: space-between on desktop.
|
||||
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
|
||||
split: "flex-col sm:items-center sm:justify-between",
|
||||
// alignment only; never touches children. Escape hatch for unusual content.
|
||||
plain: "sm:justify-end",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "actions",
|
||||
},
|
||||
});
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof dialogFooterVariants>) => (
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(dialogFooterVariants({ variant }), className)}
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -608,6 +608,7 @@ function Exports() {
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
className="text-white"
|
||||
aria-label="Delete Export"
|
||||
variant="destructive"
|
||||
onClick={() => onHandleDelete()}
|
||||
@ -657,6 +658,7 @@ function Exports() {
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="destructive"
|
||||
onClick={() => void handleDeleteCase()}
|
||||
>
|
||||
@ -742,7 +744,7 @@ function Exports() {
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
className="w-full bg-muted md:w-1/2"
|
||||
className="text-md w-full bg-muted md:w-1/2"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@ -1275,8 +1277,8 @@ function CaseEditorDialog({
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
@ -1293,7 +1295,7 @@ function CaseEditorDialog({
|
||||
? t("button.save", { ns: "common" })
|
||||
: t("toolbar.newCase")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -1425,12 +1427,13 @@ function CaseAddExportDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>
|
||||
<DialogFooter className="flex-row justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={selectedIds.length === 0 || isAdding}
|
||||
onClick={() => void handleAdd()}
|
||||
>
|
||||
|
||||
@ -567,6 +567,7 @@ function LibrarySelector({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteFace(confirmDelete);
|
||||
|
||||
@ -332,7 +332,7 @@ export default function Replay() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
className="flex items-center gap-2 text-white"
|
||||
disabled={isStopping}
|
||||
>
|
||||
{isStopping && <ActivityIndicator className="size-4" />}
|
||||
@ -355,7 +355,10 @@ export default function Replay() {
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleStop}
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "destructive" }),
|
||||
"text-white",
|
||||
)}
|
||||
>
|
||||
{t("page.confirmStop.confirm")}
|
||||
</AlertDialogAction>
|
||||
@ -684,7 +687,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-8/12 flex-row items-center justify-end">
|
||||
<div className="mr-2 w-1/3">
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
{t("debug.objectShapeFilterDrawing.score", {
|
||||
@ -694,7 +697,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||
{obj.score ? (obj.score * 100).toFixed(1).toString() : "-"}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-2 w-1/3">
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
{t("debug.objectShapeFilterDrawing.ratio", {
|
||||
@ -704,7 +707,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-2 w-1/3">
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
{t("debug.objectShapeFilterDrawing.area", {
|
||||
|
||||
@ -1616,7 +1616,7 @@ export default function Settings() {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Toaster position="top-center" />
|
||||
{!contentMobileOpen && (
|
||||
<div
|
||||
key={`mobile-menu-${selectedCamera}`}
|
||||
@ -1872,7 +1872,7 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Toaster position="top-center" />
|
||||
<div className="flex min-h-16 items-center justify-between border-b border-secondary p-3">
|
||||
<div className="mr-2 flex w-full items-center justify-between gap-3">
|
||||
<Heading as="h3" className="mb-0">
|
||||
|
||||
@ -1,856 +0,0 @@
|
||||
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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@ -81,7 +81,6 @@ 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.
|
||||
|
||||
@ -668,6 +668,7 @@ function LibrarySelector({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteCategory(confirmDelete);
|
||||
|
||||
@ -1389,8 +1389,9 @@ function MotionReview({
|
||||
selectedCells={pendingFilterCells}
|
||||
onCellsChange={setPendingFilterCells}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="justify-end gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pendingFilterCells.size === 0}
|
||||
onClick={() => {
|
||||
setPendingFilterCells(new Set());
|
||||
@ -1431,7 +1432,9 @@ function MotionReview({
|
||||
<div className="space-y-4 py-2">
|
||||
{!isDesktop && (
|
||||
<div className="space-y-1">
|
||||
<div>{t("motionPreviews.mobileSettingsTitle")}</div>
|
||||
<div className="text-md">
|
||||
{t("motionPreviews.mobileSettingsTitle")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.mobileSettingsDesc")}
|
||||
</div>
|
||||
@ -1440,7 +1443,9 @@ function MotionReview({
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("motionPreviews.speed")}</div>
|
||||
<div className="text-md">
|
||||
{t("motionPreviews.speed")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.speedDesc")}
|
||||
</div>
|
||||
@ -1469,7 +1474,7 @@ function MotionReview({
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("motionPreviews.dim")}</div>
|
||||
<div className="text-md">{t("motionPreviews.dim")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.dimDesc")}
|
||||
</div>
|
||||
|
||||
@ -784,7 +784,7 @@ export default function LiveCameraView({
|
||||
transcription != null && (
|
||||
<div
|
||||
ref={transcriptionRef}
|
||||
className="scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
|
||||
className="text-md scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
|
||||
>
|
||||
{transcription}
|
||||
</div>
|
||||
|
||||
@ -630,7 +630,7 @@ function SearchRangeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={startClock}
|
||||
@ -696,7 +696,7 @@ function SearchRangeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={endClock}
|
||||
|
||||
@ -1052,6 +1052,7 @@ export default function MotionSearchView({
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void cancelMotionSearchJob(jobId, jobCamera);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import useSWR from "swr";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { User } from "@/types/user";
|
||||
@ -789,6 +790,7 @@ export default function AuthenticationView({
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
{section === "users" && UsersSection}
|
||||
{section === "roles" && RolesSection}
|
||||
|
||||
@ -1,18 +1,12 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, 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";
|
||||
@ -21,7 +15,6 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
||||
import {
|
||||
LuCheck,
|
||||
LuCopy,
|
||||
LuExternalLink,
|
||||
LuGripVertical,
|
||||
LuPencil,
|
||||
@ -29,7 +22,6 @@ 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";
|
||||
@ -58,7 +50,6 @@ 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,
|
||||
@ -97,7 +88,6 @@ 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);
|
||||
@ -227,6 +217,12 @@ 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">
|
||||
@ -250,7 +246,7 @@ export default function CameraManagementView({
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="mb-2 flex max-w-48 items-center gap-2"
|
||||
className="mb-2 flex max-w-48 items-center gap-2 text-white"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
{t("cameraManagement.deleteCamera")}
|
||||
@ -258,27 +254,6 @@ 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="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={
|
||||
@ -389,10 +364,6 @@ export default function CameraManagementView({
|
||||
onClose={() => setRestartDialogOpen(false)}
|
||||
onRestart={() => sendRestart("restart")}
|
||||
/>
|
||||
<CloneCameraDialog
|
||||
open={showCloneDialog}
|
||||
onClose={() => setShowCloneDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -530,7 +501,6 @@ function CameraStatusSelect({
|
||||
]);
|
||||
const { payload: enabledState, send: sendEnabled } =
|
||||
useEnabledState(cameraName);
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const currentStatus: CameraStatus = isDisabledInConfig
|
||||
@ -616,12 +586,6 @@ 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",
|
||||
@ -653,7 +617,6 @@ function CameraStatusSelect({
|
||||
onConfigChanged,
|
||||
sendEnabled,
|
||||
setRestartDialogOpen,
|
||||
statusBar,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user