mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-17 21:58:22 +03:00
Merge 78bc11d7e0 into 722ef6a1fe
This commit is contained in:
commit
86bfe11885
188
docs/docs/configuration/profiles.md
Normal file
188
docs/docs/configuration/profiles.md
Normal file
@ -0,0 +1,188 @@
|
||||
---
|
||||
id: profiles
|
||||
title: Profiles
|
||||
---
|
||||
|
||||
Profiles allow you to define named sets of camera configuration overrides that can be activated and deactivated at runtime without restarting Frigate. This is useful for scenarios like switching between "Home" and "Away" modes, daytime and nighttime configurations, or any situation where you want to quickly change how multiple cameras behave.
|
||||
|
||||
## How Profiles Work
|
||||
|
||||
Profiles operate as a two-level system:
|
||||
|
||||
1. **Profile definitions** are declared at the top level of your config under `profiles`. Each definition has a machine name (the key) and a `friendly_name` for display in the UI.
|
||||
2. **Camera profile overrides** are declared under each camera's `profiles` section, keyed by the profile name. Only the settings you want to change need to be specified — everything else is inherited from the camera's base configuration.
|
||||
|
||||
When a profile is activated, Frigate merges each camera's profile overrides on top of its base config. When the profile is deactivated, all cameras revert to their original settings. Only one profile can be active at a time.
|
||||
|
||||
:::info
|
||||
|
||||
Profile changes are applied in-memory and take effect immediately — no restart is required. The active profile is persisted across Frigate restarts (stored in the `/config/.active_profile` file).
|
||||
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
The easiest way to define profiles is to use the Frigate UI. Profiles can also be configured manually in your configuration file.
|
||||
|
||||
### Using the UI
|
||||
|
||||
To create and manage profiles from the UI, open **Settings**. From there you can:
|
||||
|
||||
1. **Create a profile** — Navigate to **Profiles**. Click the **Add Profile** button, enter a name (and optionally a profile ID).
|
||||
2. **Configure overrides** — Navigate to a camera configuration section (e.g. Motion detection, Record, Notifications). In the top right, two buttons will appear - choose a camera and a profile from the profile selector to edit overrides for that camera and section. Only the fields you change will be stored as overrides — fields that require a restart are hidden since profiles are applied at runtime. You can click the **Remove Profile Override** button
|
||||
3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to **Profiles**, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers.
|
||||
4. **Delete a profile** — Navigate to **Profiles**, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
|
||||
|
||||
### Defining Profiles in YAML
|
||||
|
||||
First, define your profiles at the top level of your Frigate config. Every profile name referenced by a camera must be defined here.
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
home:
|
||||
friendly_name: Home
|
||||
away:
|
||||
friendly_name: Away
|
||||
night:
|
||||
friendly_name: Night Mode
|
||||
```
|
||||
|
||||
### Camera Profile Overrides
|
||||
|
||||
Under each camera, add a `profiles` section with overrides for each profile. You only need to include the settings you want to change.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
front_door:
|
||||
ffmpeg:
|
||||
inputs:
|
||||
- path: rtsp://camera:554/stream
|
||||
roles:
|
||||
- detect
|
||||
- record
|
||||
detect:
|
||||
enabled: true
|
||||
record:
|
||||
enabled: true
|
||||
profiles:
|
||||
away:
|
||||
detect:
|
||||
enabled: true
|
||||
notifications:
|
||||
enabled: true
|
||||
objects:
|
||||
track:
|
||||
- person
|
||||
- car
|
||||
- package
|
||||
review:
|
||||
alerts:
|
||||
labels:
|
||||
- person
|
||||
- car
|
||||
- package
|
||||
home:
|
||||
detect:
|
||||
enabled: true
|
||||
notifications:
|
||||
enabled: false
|
||||
objects:
|
||||
track:
|
||||
- person
|
||||
```
|
||||
|
||||
### Supported Override Sections
|
||||
|
||||
The following camera configuration sections can be overridden in a profile:
|
||||
|
||||
| Section | Description |
|
||||
| ------------------ | ----------------------------------------- |
|
||||
| `enabled` | Enable or disable the camera entirely |
|
||||
| `audio` | Audio detection settings |
|
||||
| `birdseye` | Birdseye view settings |
|
||||
| `detect` | Object detection settings |
|
||||
| `face_recognition` | Face recognition settings |
|
||||
| `lpr` | License plate recognition settings |
|
||||
| `motion` | Motion detection settings |
|
||||
| `notifications` | Notification settings |
|
||||
| `objects` | Object tracking and filter settings |
|
||||
| `record` | Recording settings |
|
||||
| `review` | Review alert and detection settings |
|
||||
| `snapshots` | Snapshot settings |
|
||||
| `zones` | Zone definitions (merged with base zones) |
|
||||
|
||||
:::note
|
||||
|
||||
Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For zones, profile zones are merged with the camera's base zones — any zone defined in the profile will override or add to the base zones.
|
||||
|
||||
:::
|
||||
|
||||
## Activating Profiles
|
||||
|
||||
Profiles can be activated and deactivated from the Frigate UI. Open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
|
||||
|
||||
## Example: Home / Away Setup
|
||||
|
||||
A common use case is having different detection and notification settings based on whether you are home or away.
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
home:
|
||||
friendly_name: Home
|
||||
away:
|
||||
friendly_name: Away
|
||||
|
||||
cameras:
|
||||
front_door:
|
||||
ffmpeg:
|
||||
inputs:
|
||||
- path: rtsp://camera:554/stream
|
||||
roles:
|
||||
- detect
|
||||
- record
|
||||
detect:
|
||||
enabled: true
|
||||
record:
|
||||
enabled: true
|
||||
notifications:
|
||||
enabled: false
|
||||
profiles:
|
||||
away:
|
||||
notifications:
|
||||
enabled: true
|
||||
review:
|
||||
alerts:
|
||||
labels:
|
||||
- person
|
||||
- car
|
||||
home:
|
||||
notifications:
|
||||
enabled: false
|
||||
|
||||
indoor_cam:
|
||||
ffmpeg:
|
||||
inputs:
|
||||
- path: rtsp://camera:554/indoor
|
||||
roles:
|
||||
- detect
|
||||
- record
|
||||
detect:
|
||||
enabled: false
|
||||
record:
|
||||
enabled: false
|
||||
profiles:
|
||||
away:
|
||||
enabled: true
|
||||
detect:
|
||||
enabled: true
|
||||
record:
|
||||
enabled: true
|
||||
home:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- **Away profile**: The front door camera enables notifications and tracks specific alert labels. The indoor camera is fully enabled with detection and recording.
|
||||
- **Home profile**: The front door camera disables notifications. The indoor camera is completely disabled for privacy.
|
||||
- **No profile active**: All cameras use their base configuration values.
|
||||
@ -1026,6 +1026,49 @@ cameras:
|
||||
actions:
|
||||
- notification
|
||||
|
||||
# Optional: Named config profiles with partial overrides that can be activated at runtime.
|
||||
# NOTE: Profile names must be defined in the top-level 'profiles' section.
|
||||
profiles:
|
||||
# Required: name of the profile (must match a top-level profile definition)
|
||||
away:
|
||||
# Optional: Enable or disable the camera when this profile is active (default: not set, inherits base)
|
||||
enabled: true
|
||||
# Optional: Override audio settings
|
||||
audio:
|
||||
enabled: true
|
||||
# Optional: Override birdseye settings
|
||||
# birdseye:
|
||||
# Optional: Override detect settings
|
||||
detect:
|
||||
enabled: true
|
||||
# Optional: Override face_recognition settings
|
||||
# face_recognition:
|
||||
# Optional: Override lpr settings
|
||||
# lpr:
|
||||
# Optional: Override motion settings
|
||||
# motion:
|
||||
# Optional: Override notification settings
|
||||
notifications:
|
||||
enabled: true
|
||||
# Optional: Override objects settings
|
||||
objects:
|
||||
track:
|
||||
- person
|
||||
- car
|
||||
# Optional: Override record settings
|
||||
record:
|
||||
enabled: true
|
||||
# Optional: Override review settings
|
||||
review:
|
||||
alerts:
|
||||
labels:
|
||||
- person
|
||||
- car
|
||||
# Optional: Override snapshot settings
|
||||
# snapshots:
|
||||
# Optional: Override or add zones (merged with base zones)
|
||||
# zones:
|
||||
|
||||
# Optional
|
||||
ui:
|
||||
# Optional: Set a timezone to use in the UI (default: use browser local time)
|
||||
@ -1092,4 +1135,14 @@ camera_groups:
|
||||
icon: LuCar
|
||||
# Required: index of this group
|
||||
order: 0
|
||||
|
||||
# Optional: Profile definitions for named config overrides
|
||||
# NOTE: Profile names defined here can be referenced in camera profiles sections
|
||||
profiles:
|
||||
# Required: name of the profile (machine name used internally)
|
||||
home:
|
||||
# Required: display name shown in the UI
|
||||
friendly_name: Home
|
||||
away:
|
||||
friendly_name: Away
|
||||
```
|
||||
|
||||
@ -275,6 +275,25 @@ Same data available at `/api/stats` published at a configurable interval.
|
||||
|
||||
Returns data about each camera, its current features, and if it is detecting motion, objects, etc. Can be triggered by publising to `frigate/onConnect`
|
||||
|
||||
### `frigate/profile/set`
|
||||
|
||||
Topic to activate or deactivate a [profile](/configuration/profiles). Publish a profile name to activate it, or `none` to deactivate the current profile.
|
||||
|
||||
### `frigate/profile/state`
|
||||
|
||||
Topic with the currently active profile name. Published value is the profile name or `none` if no profile is active. This topic is retained.
|
||||
|
||||
### `frigate/profiles/available`
|
||||
|
||||
Topic with a JSON array of all available profile definitions. Published on startup as a retained message.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "away", "friendly_name": "Away" },
|
||||
{ "name": "home", "friendly_name": "Home" }
|
||||
]
|
||||
```
|
||||
|
||||
### `frigate/notifications/set`
|
||||
|
||||
Topic to turn notifications on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
@ -94,6 +94,7 @@ const sidebars: SidebarsConfig = {
|
||||
"Extra Configuration": [
|
||||
"configuration/authentication",
|
||||
"configuration/notifications",
|
||||
"configuration/profiles",
|
||||
"configuration/ffmpeg_presets",
|
||||
"configuration/pwa",
|
||||
"configuration/tls",
|
||||
|
||||
@ -31,7 +31,11 @@ from frigate.api.auth import (
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||
from frigate.api.defs.request.app_body import AppConfigSetBody, MediaSyncBody
|
||||
from frigate.api.defs.request.app_body import (
|
||||
AppConfigSetBody,
|
||||
MediaSyncBody,
|
||||
ProfileSetBody,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
@ -154,6 +158,31 @@ def config(request: Request):
|
||||
for zone_name, zone in config_obj.cameras[camera_name].zones.items():
|
||||
camera_dict["zones"][zone_name]["color"] = zone.color
|
||||
|
||||
# Re-dump profile overrides with exclude_unset so that only
|
||||
# explicitly-set fields are returned (not Pydantic defaults).
|
||||
# Without this, the frontend merges defaults (e.g. threshold=30)
|
||||
# over the camera's actual base values (e.g. threshold=20).
|
||||
if camera.profiles:
|
||||
for profile_name, profile_config in camera.profiles.items():
|
||||
camera_dict.setdefault("profiles", {})[profile_name] = (
|
||||
profile_config.model_dump(
|
||||
mode="json", warnings="none", exclude_unset=True
|
||||
)
|
||||
)
|
||||
|
||||
# When a profile is active, the top-level camera sections contain
|
||||
# profile-merged (effective) values. Include the original base
|
||||
# configs so the frontend settings can display them separately.
|
||||
if (
|
||||
config_obj.active_profile is not None
|
||||
and request.app.profile_manager is not None
|
||||
):
|
||||
base_sections = request.app.profile_manager.get_base_configs_for_api(
|
||||
camera_name
|
||||
)
|
||||
if base_sections:
|
||||
camera_dict["base_config"] = base_sections
|
||||
|
||||
# remove go2rtc stream passwords
|
||||
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
@ -201,6 +230,39 @@ def config(request: Request):
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_profiles(request: Request):
|
||||
"""List all available profiles and the currently active profile."""
|
||||
profile_manager = request.app.profile_manager
|
||||
return JSONResponse(content=profile_manager.get_profile_info())
|
||||
|
||||
|
||||
@router.get("/profile/active", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_active_profile(request: Request):
|
||||
"""Get the currently active profile."""
|
||||
config_obj: FrigateConfig = request.app.frigate_config
|
||||
return JSONResponse(content={"active_profile": config_obj.active_profile})
|
||||
|
||||
|
||||
@router.put("/profile/set", dependencies=[Depends(require_role(["admin"]))])
|
||||
def set_profile(request: Request, body: ProfileSetBody):
|
||||
"""Activate or deactivate a profile."""
|
||||
profile_manager = request.app.profile_manager
|
||||
err = profile_manager.activate_profile(body.profile)
|
||||
if err:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": err},
|
||||
status_code=400,
|
||||
)
|
||||
request.app.dispatcher.publish("profile/state", body.profile or "none", retain=True)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"active_profile": body.profile,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())])
|
||||
def ffmpeg_presets():
|
||||
"""Return available ffmpeg preset keys for config UI usage."""
|
||||
@ -589,6 +651,9 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
request.app.frigate_config = config
|
||||
request.app.genai_manager.update_config(config)
|
||||
|
||||
if request.app.profile_manager is not None:
|
||||
request.app.profile_manager.update_config(config)
|
||||
|
||||
if request.app.stats_emitter is not None:
|
||||
request.app.stats_emitter.config = config
|
||||
|
||||
|
||||
@ -30,6 +30,12 @@ class AppPutRoleBody(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
class ProfileSetBody(BaseModel):
|
||||
profile: Optional[str] = Field(
|
||||
default=None, description="Profile name to activate, or null to deactivate"
|
||||
)
|
||||
|
||||
|
||||
class MediaSyncBody(BaseModel):
|
||||
dry_run: bool = Field(
|
||||
default=True, description="If True, only report orphans without deleting them"
|
||||
|
||||
@ -69,6 +69,8 @@ def create_fastapi_app(
|
||||
event_metadata_updater: EventMetadataPublisher,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: DebugReplayManager,
|
||||
dispatcher=None,
|
||||
profile_manager=None,
|
||||
enforce_default_admin: bool = True,
|
||||
):
|
||||
logger.info("Starting FastAPI app")
|
||||
@ -151,6 +153,8 @@ def create_fastapi_app(
|
||||
app.event_metadata_updater = event_metadata_updater
|
||||
app.config_publisher = config_publisher
|
||||
app.replay_manager = replay_manager
|
||||
app.dispatcher = dispatcher
|
||||
app.profile_manager = profile_manager
|
||||
|
||||
if frigate_config.auth.enabled:
|
||||
secret = get_jwt_secret()
|
||||
|
||||
@ -30,6 +30,7 @@ from frigate.comms.ws import WebSocketClient
|
||||
from frigate.comms.zmq_proxy import ZmqProxy
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.config.config import FrigateConfig
|
||||
from frigate.config.profile_manager import ProfileManager
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
@ -118,6 +119,7 @@ class FrigateApp:
|
||||
self.ptz_metrics: dict[str, PTZMetrics] = {}
|
||||
self.processes: dict[str, int] = {}
|
||||
self.embeddings: Optional[EmbeddingsContext] = None
|
||||
self.profile_manager: Optional[ProfileManager] = None
|
||||
self.config = config
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
@ -349,6 +351,19 @@ class FrigateApp:
|
||||
comms,
|
||||
)
|
||||
|
||||
def init_profile_manager(self) -> None:
|
||||
self.profile_manager = ProfileManager(
|
||||
self.config, self.inter_config_updater, self.dispatcher
|
||||
)
|
||||
self.dispatcher.profile_manager = self.profile_manager
|
||||
|
||||
persisted = ProfileManager.load_persisted_profile()
|
||||
if persisted and any(
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
logger.info("Restoring persisted profile '%s'", persisted)
|
||||
self.profile_manager.activate_profile(persisted)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
for name in self.config.cameras.keys():
|
||||
try:
|
||||
@ -557,6 +572,7 @@ class FrigateApp:
|
||||
self.init_inter_process_communicator()
|
||||
self.start_detectors()
|
||||
self.init_dispatcher()
|
||||
self.init_profile_manager()
|
||||
self.init_embeddings_client()
|
||||
self.start_video_output_processor()
|
||||
self.start_ptz_autotracker()
|
||||
@ -586,6 +602,8 @@ class FrigateApp:
|
||||
self.event_metadata_updater,
|
||||
self.inter_config_updater,
|
||||
self.replay_manager,
|
||||
self.dispatcher,
|
||||
self.profile_manager,
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=5001,
|
||||
|
||||
@ -16,6 +16,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
|
||||
from frigate.config.profile_manager import ProfileManager
|
||||
from frigate.const import (
|
||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
@ -91,7 +92,9 @@ class Dispatcher:
|
||||
}
|
||||
self._global_settings_handlers: dict[str, Callable] = {
|
||||
"notifications": self._on_global_notification_command,
|
||||
"profile": self._on_profile_command,
|
||||
}
|
||||
self.profile_manager: Optional[ProfileManager] = None
|
||||
|
||||
for comm in self.comms:
|
||||
comm.subscribe(self._receive)
|
||||
@ -298,6 +301,11 @@ class Dispatcher:
|
||||
)
|
||||
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
|
||||
self.publish("audio_detections", json.dumps(audio_detections))
|
||||
self.publish(
|
||||
"profile/state",
|
||||
self.config.active_profile or "none",
|
||||
retain=True,
|
||||
)
|
||||
|
||||
def handle_notification_test() -> None:
|
||||
self.publish("notification_test", "Test notification")
|
||||
@ -556,6 +564,22 @@ class Dispatcher:
|
||||
)
|
||||
self.publish("notifications/state", payload, retain=True)
|
||||
|
||||
def _on_profile_command(self, payload: str) -> None:
|
||||
"""Callback for profile/set topic."""
|
||||
if self.profile_manager is None:
|
||||
logger.error("Profile manager not initialized")
|
||||
return
|
||||
|
||||
profile_name = (
|
||||
payload.strip() if payload.strip() not in ("", "none", "None") else None
|
||||
)
|
||||
err = self.profile_manager.activate_profile(profile_name)
|
||||
if err:
|
||||
logger.error("Failed to activate profile: %s", err)
|
||||
return
|
||||
|
||||
self.publish("profile/state", payload.strip() or "none", retain=True)
|
||||
|
||||
def _on_audio_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for audio topic."""
|
||||
audio_settings = self.config.cameras[camera_name].audio
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
@ -163,6 +164,21 @@ class MqttClient(Communicator):
|
||||
retain=True,
|
||||
)
|
||||
|
||||
self.publish(
|
||||
"profile/state",
|
||||
self.config.active_profile or "none",
|
||||
retain=True,
|
||||
)
|
||||
available_profiles = [
|
||||
{"name": name, "friendly_name": defn.friendly_name}
|
||||
for name, defn in sorted(self.config.profiles.items())
|
||||
]
|
||||
self.publish(
|
||||
"profiles/available",
|
||||
json.dumps(available_profiles),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
self.publish("available", "online", retain=True)
|
||||
|
||||
def on_mqtt_command(
|
||||
@ -289,6 +305,11 @@ class MqttClient(Communicator):
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/profile/set",
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/onConnect", self.on_mqtt_command
|
||||
)
|
||||
|
||||
@ -34,6 +34,7 @@ from .mqtt import CameraMqttConfig
|
||||
from .notification import NotificationConfig
|
||||
from .objects import ObjectConfig
|
||||
from .onvif import OnvifConfig
|
||||
from .profile import CameraProfileConfig
|
||||
from .record import RecordConfig
|
||||
from .review import ReviewConfig
|
||||
from .snapshots import SnapshotsConfig
|
||||
@ -184,6 +185,12 @@ class CameraConfig(FrigateBaseModel):
|
||||
title="Camera URL",
|
||||
description="URL to visit the camera directly from system page",
|
||||
)
|
||||
|
||||
profiles: dict[str, CameraProfileConfig] = Field(
|
||||
default_factory=dict,
|
||||
title="Profiles",
|
||||
description="Named config profiles with partial overrides that can be activated at runtime.",
|
||||
)
|
||||
zones: dict[str, ZoneConfig] = Field(
|
||||
default_factory=dict,
|
||||
title="Zones",
|
||||
|
||||
44
frigate/config/camera/profile.py
Normal file
44
frigate/config/camera/profile.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Camera profile configuration for named config overrides."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..classification import (
|
||||
CameraFaceRecognitionConfig,
|
||||
CameraLicensePlateRecognitionConfig,
|
||||
)
|
||||
from .audio import AudioConfig
|
||||
from .birdseye import BirdseyeCameraConfig
|
||||
from .detect import DetectConfig
|
||||
from .motion import MotionConfig
|
||||
from .notification import NotificationConfig
|
||||
from .objects import ObjectConfig
|
||||
from .record import RecordConfig
|
||||
from .review import ReviewConfig
|
||||
from .snapshots import SnapshotsConfig
|
||||
from .zone import ZoneConfig
|
||||
|
||||
__all__ = ["CameraProfileConfig"]
|
||||
|
||||
|
||||
class CameraProfileConfig(FrigateBaseModel):
|
||||
"""A named profile containing partial camera config overrides.
|
||||
|
||||
Sections set to None inherit from the camera's base config.
|
||||
Sections that are defined get Pydantic-validated, then only
|
||||
explicitly-set fields are used as overrides via exclude_unset.
|
||||
"""
|
||||
|
||||
enabled: Optional[bool] = None
|
||||
audio: Optional[AudioConfig] = None
|
||||
birdseye: Optional[BirdseyeCameraConfig] = None
|
||||
detect: Optional[DetectConfig] = None
|
||||
face_recognition: Optional[CameraFaceRecognitionConfig] = None
|
||||
lpr: Optional[CameraLicensePlateRecognitionConfig] = None
|
||||
motion: Optional[MotionConfig] = None
|
||||
notifications: Optional[NotificationConfig] = None
|
||||
objects: Optional[ObjectConfig] = None
|
||||
record: Optional[RecordConfig] = None
|
||||
review: Optional[ReviewConfig] = None
|
||||
snapshots: Optional[SnapshotsConfig] = None
|
||||
zones: Optional[dict[str, ZoneConfig]] = None
|
||||
@ -27,6 +27,8 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
review = "review"
|
||||
review_genai = "review_genai"
|
||||
semantic_search = "semantic_search" # for semantic search triggers
|
||||
face_recognition = "face_recognition"
|
||||
lpr = "lpr"
|
||||
snapshots = "snapshots"
|
||||
zones = "zones"
|
||||
|
||||
@ -119,6 +121,10 @@ class CameraConfigUpdateSubscriber:
|
||||
config.review.genai = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.semantic_search:
|
||||
config.semantic_search = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.face_recognition:
|
||||
config.face_recognition = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.lpr:
|
||||
config.lpr = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.snapshots:
|
||||
config.snapshots = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.zones:
|
||||
|
||||
@ -12,7 +12,6 @@ from pydantic import (
|
||||
Field,
|
||||
TypeAdapter,
|
||||
ValidationInfo,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
@ -68,6 +67,7 @@ from .env import EnvVars
|
||||
from .logger import LoggerConfig
|
||||
from .mqtt import MqttConfig
|
||||
from .network import NetworkingConfig
|
||||
from .profile import ProfileDefinitionConfig
|
||||
from .proxy import ProxyConfig
|
||||
from .telemetry import TelemetryConfig
|
||||
from .tls import TlsConfig
|
||||
@ -97,8 +97,7 @@ stream_info_retriever = StreamInfoRetriever()
|
||||
class RuntimeMotionConfig(MotionConfig):
|
||||
"""Runtime version of MotionConfig with rasterized masks."""
|
||||
|
||||
# The rasterized numpy mask (combination of all enabled masks)
|
||||
rasterized_mask: np.ndarray = None
|
||||
rasterized_mask: np.ndarray = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, **config):
|
||||
frame_shape = config.get("frame_shape", (1, 1))
|
||||
@ -144,24 +143,13 @@ class RuntimeMotionConfig(MotionConfig):
|
||||
empty_mask[:] = 255
|
||||
self.rasterized_mask = empty_mask
|
||||
|
||||
def dict(self, **kwargs):
|
||||
ret = super().model_dump(**kwargs)
|
||||
if "rasterized_mask" in ret:
|
||||
ret.pop("rasterized_mask")
|
||||
return ret
|
||||
|
||||
@field_serializer("rasterized_mask", when_used="json")
|
||||
def serialize_rasterized_mask(self, value: Any, info):
|
||||
return None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore")
|
||||
|
||||
|
||||
class RuntimeFilterConfig(FilterConfig):
|
||||
"""Runtime version of FilterConfig with rasterized masks."""
|
||||
|
||||
# The rasterized numpy mask (combination of all enabled masks)
|
||||
rasterized_mask: Optional[np.ndarray] = None
|
||||
rasterized_mask: Optional[np.ndarray] = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, **config):
|
||||
frame_shape = config.get("frame_shape", (1, 1))
|
||||
@ -225,16 +213,6 @@ class RuntimeFilterConfig(FilterConfig):
|
||||
else:
|
||||
self.rasterized_mask = None
|
||||
|
||||
def dict(self, **kwargs):
|
||||
ret = super().model_dump(**kwargs)
|
||||
if "rasterized_mask" in ret:
|
||||
ret.pop("rasterized_mask")
|
||||
return ret
|
||||
|
||||
@field_serializer("rasterized_mask", when_used="json")
|
||||
def serialize_rasterized_mask(self, value: Any, info):
|
||||
return None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore")
|
||||
|
||||
|
||||
@ -561,6 +539,19 @@ class FrigateConfig(FrigateBaseModel):
|
||||
description="Configuration for named camera groups used to organize cameras in the UI.",
|
||||
)
|
||||
|
||||
profiles: Dict[str, ProfileDefinitionConfig] = Field(
|
||||
default_factory=dict,
|
||||
title="Profiles",
|
||||
description="Named profile definitions with friendly names. Camera profiles must reference names defined here.",
|
||||
)
|
||||
|
||||
active_profile: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Active profile",
|
||||
description="Currently active profile name. Runtime-only, not persisted in YAML.",
|
||||
exclude=True,
|
||||
)
|
||||
|
||||
_plus_api: PlusApi
|
||||
|
||||
@property
|
||||
@ -910,6 +901,15 @@ class FrigateConfig(FrigateBaseModel):
|
||||
verify_objects_track(camera_config, labelmap_objects)
|
||||
verify_lpr_and_face(self, camera_config)
|
||||
|
||||
# Validate camera profiles reference top-level profile definitions
|
||||
for cam_name, cam_config in self.cameras.items():
|
||||
for profile_name in cam_config.profiles:
|
||||
if profile_name not in self.profiles:
|
||||
raise ValueError(
|
||||
f"Camera '{cam_name}' references profile '{profile_name}' "
|
||||
f"which is not defined in the top-level 'profiles' section"
|
||||
)
|
||||
|
||||
# set names on classification configs
|
||||
for name, config in self.classification.custom.items():
|
||||
config.name = name
|
||||
|
||||
20
frigate/config/profile.py
Normal file
20
frigate/config/profile.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""Top-level profile definition configuration."""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
|
||||
__all__ = ["ProfileDefinitionConfig"]
|
||||
|
||||
|
||||
class ProfileDefinitionConfig(FrigateBaseModel):
|
||||
"""Defines a named profile with a human-readable display name.
|
||||
|
||||
The dict key is the machine name used internally; friendly_name
|
||||
is the label shown in the UI and API responses.
|
||||
"""
|
||||
|
||||
friendly_name: str = Field(
|
||||
title="Friendly name",
|
||||
description="Display name for this profile shown in the UI.",
|
||||
)
|
||||
334
frigate/config/profile_manager.py
Normal file
334
frigate/config/profile_manager.py
Normal file
@ -0,0 +1,334 @@
|
||||
"""Profile manager for activating/deactivating named config profiles."""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdatePublisher,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.camera.zone import ZoneConfig
|
||||
from frigate.const import CONFIG_DIR
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.config import apply_section_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
|
||||
"audio": CameraConfigUpdateEnum.audio,
|
||||
"birdseye": CameraConfigUpdateEnum.birdseye,
|
||||
"detect": CameraConfigUpdateEnum.detect,
|
||||
"face_recognition": CameraConfigUpdateEnum.face_recognition,
|
||||
"lpr": CameraConfigUpdateEnum.lpr,
|
||||
"motion": CameraConfigUpdateEnum.motion,
|
||||
"notifications": CameraConfigUpdateEnum.notifications,
|
||||
"objects": CameraConfigUpdateEnum.objects,
|
||||
"record": CameraConfigUpdateEnum.record,
|
||||
"review": CameraConfigUpdateEnum.review,
|
||||
"snapshots": CameraConfigUpdateEnum.snapshots,
|
||||
"zones": CameraConfigUpdateEnum.zones,
|
||||
}
|
||||
|
||||
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile"
|
||||
|
||||
|
||||
class ProfileManager:
|
||||
"""Manages profile activation, persistence, and config application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
config_updater: CameraConfigUpdatePublisher,
|
||||
dispatcher=None,
|
||||
):
|
||||
from frigate.config.config import FrigateConfig
|
||||
|
||||
self.config: FrigateConfig = config
|
||||
self.config_updater = config_updater
|
||||
self.dispatcher = dispatcher
|
||||
self._base_configs: dict[str, dict[str, dict]] = {}
|
||||
self._base_api_configs: dict[str, dict[str, dict]] = {}
|
||||
self._base_enabled: dict[str, bool] = {}
|
||||
self._base_zones: dict[str, dict[str, ZoneConfig]] = {}
|
||||
self._snapshot_base_configs()
|
||||
|
||||
def _snapshot_base_configs(self) -> None:
|
||||
"""Snapshot each camera's current section configs, enabled, and zones."""
|
||||
for cam_name, cam_config in self.config.cameras.items():
|
||||
self._base_configs[cam_name] = {}
|
||||
self._base_api_configs[cam_name] = {}
|
||||
self._base_enabled[cam_name] = cam_config.enabled
|
||||
self._base_zones[cam_name] = copy.deepcopy(cam_config.zones)
|
||||
for section in PROFILE_SECTION_UPDATES:
|
||||
section_value = getattr(cam_config, section, None)
|
||||
if section_value is None:
|
||||
continue
|
||||
|
||||
if section == "zones":
|
||||
# zones is a dict of ZoneConfig models
|
||||
self._base_configs[cam_name][section] = {
|
||||
name: zone.model_dump() for name, zone in section_value.items()
|
||||
}
|
||||
self._base_api_configs[cam_name][section] = {
|
||||
name: {
|
||||
**zone.model_dump(
|
||||
mode="json",
|
||||
warnings="none",
|
||||
exclude_none=True,
|
||||
),
|
||||
"color": zone.color,
|
||||
}
|
||||
for name, zone in section_value.items()
|
||||
}
|
||||
else:
|
||||
self._base_configs[cam_name][section] = section_value.model_dump()
|
||||
self._base_api_configs[cam_name][section] = (
|
||||
section_value.model_dump(
|
||||
mode="json",
|
||||
warnings="none",
|
||||
exclude_none=True,
|
||||
)
|
||||
)
|
||||
|
||||
def update_config(self, new_config) -> None:
|
||||
"""Update config reference after config/set replaces the in-memory config.
|
||||
|
||||
Preserves active profile state: re-snapshots base configs from the new
|
||||
(freshly parsed) config, then re-applies profile overrides if a profile
|
||||
was active.
|
||||
"""
|
||||
current_active = self.config.active_profile
|
||||
self.config = new_config
|
||||
|
||||
# Re-snapshot base configs from the new config (which has base values)
|
||||
self._base_configs.clear()
|
||||
self._base_api_configs.clear()
|
||||
self._base_enabled.clear()
|
||||
self._base_zones.clear()
|
||||
self._snapshot_base_configs()
|
||||
|
||||
# Re-apply profile overrides without publishing ZMQ updates
|
||||
# (the config/set caller handles its own ZMQ publishing)
|
||||
if current_active is not None:
|
||||
if current_active in self.config.profiles:
|
||||
changed: dict[str, set[str]] = {}
|
||||
self._apply_profile_overrides(current_active, changed)
|
||||
self.config.active_profile = current_active
|
||||
else:
|
||||
# Profile was deleted — deactivate
|
||||
self.config.active_profile = None
|
||||
self._persist_active_profile(None)
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
"""
|
||||
if profile_name is not None:
|
||||
if profile_name not in self.config.profiles:
|
||||
return (
|
||||
f"Profile '{profile_name}' is not defined in the profiles section"
|
||||
)
|
||||
|
||||
# Track which camera/section pairs get changed for ZMQ publishing
|
||||
changed: dict[str, set[str]] = {}
|
||||
|
||||
# Reset all cameras to base config
|
||||
self._reset_to_base(changed)
|
||||
|
||||
# Apply new profile overrides if activating
|
||||
if profile_name is not None:
|
||||
err = self._apply_profile_overrides(profile_name, changed)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Publish ZMQ updates only for sections that actually changed
|
||||
self._publish_updates(changed)
|
||||
|
||||
self.config.active_profile = profile_name
|
||||
self._persist_active_profile(profile_name)
|
||||
logger.info(
|
||||
"Profile %s",
|
||||
f"'{profile_name}' activated" if profile_name else "deactivated",
|
||||
)
|
||||
return None
|
||||
|
||||
def _reset_to_base(self, changed: dict[str, set[str]]) -> None:
|
||||
"""Reset all cameras to their base (no-profile) config."""
|
||||
for cam_name, cam_config in self.config.cameras.items():
|
||||
# Restore enabled state
|
||||
base_enabled = self._base_enabled.get(cam_name)
|
||||
if base_enabled is not None and cam_config.enabled != base_enabled:
|
||||
cam_config.enabled = base_enabled
|
||||
changed.setdefault(cam_name, set()).add("enabled")
|
||||
|
||||
# Restore zones (always restore from snapshot; direct Pydantic
|
||||
# comparison fails when ZoneConfig contains numpy arrays)
|
||||
base_zones = self._base_zones.get(cam_name)
|
||||
if base_zones is not None:
|
||||
cam_config.zones = copy.deepcopy(base_zones)
|
||||
changed.setdefault(cam_name, set()).add("zones")
|
||||
|
||||
# Restore section configs (zones handled above)
|
||||
base = self._base_configs.get(cam_name, {})
|
||||
for section in PROFILE_SECTION_UPDATES:
|
||||
if section == "zones":
|
||||
continue
|
||||
base_data = base.get(section)
|
||||
if base_data is None:
|
||||
continue
|
||||
err = apply_section_update(cam_config, section, base_data)
|
||||
if err:
|
||||
logger.error(
|
||||
"Failed to reset section '%s' on camera '%s': %s",
|
||||
section,
|
||||
cam_name,
|
||||
err,
|
||||
)
|
||||
else:
|
||||
changed.setdefault(cam_name, set()).add(section)
|
||||
|
||||
def _apply_profile_overrides(
|
||||
self, profile_name: str, changed: dict[str, set[str]]
|
||||
) -> Optional[str]:
|
||||
"""Apply profile overrides for all cameras that have the named profile."""
|
||||
for cam_name, cam_config in self.config.cameras.items():
|
||||
profile = cam_config.profiles.get(profile_name)
|
||||
if profile is None:
|
||||
continue
|
||||
|
||||
# Apply enabled override
|
||||
if profile.enabled is not None and cam_config.enabled != profile.enabled:
|
||||
cam_config.enabled = profile.enabled
|
||||
changed.setdefault(cam_name, set()).add("enabled")
|
||||
|
||||
# Apply zones override — merge profile zones into base zones
|
||||
if profile.zones is not None:
|
||||
base_zones = self._base_zones.get(cam_name, {})
|
||||
merged_zones = copy.deepcopy(base_zones)
|
||||
merged_zones.update(profile.zones)
|
||||
# Profile zone objects are parsed without colors or contours
|
||||
# (those are set during CameraConfig init / post-validation).
|
||||
# Inherit the base zone's color when available, and ensure
|
||||
# every zone has a valid contour for rendering.
|
||||
for name, zone in merged_zones.items():
|
||||
if zone.contour.size == 0:
|
||||
zone.generate_contour(cam_config.frame_shape)
|
||||
if zone.color == (0, 0, 0) and name in base_zones:
|
||||
zone._color = base_zones[name].color
|
||||
cam_config.zones = merged_zones
|
||||
changed.setdefault(cam_name, set()).add("zones")
|
||||
|
||||
base = self._base_configs.get(cam_name, {})
|
||||
|
||||
for section in PROFILE_SECTION_UPDATES:
|
||||
if section == "zones":
|
||||
continue
|
||||
profile_section = getattr(profile, section, None)
|
||||
if profile_section is None:
|
||||
continue
|
||||
|
||||
overrides = profile_section.model_dump(exclude_unset=True)
|
||||
if not overrides:
|
||||
continue
|
||||
|
||||
base_data = base.get(section, {})
|
||||
merged = deep_merge(overrides, base_data)
|
||||
|
||||
err = apply_section_update(cam_config, section, merged)
|
||||
if err:
|
||||
return f"Failed to apply profile '{profile_name}' section '{section}' on camera '{cam_name}': {err}"
|
||||
|
||||
changed.setdefault(cam_name, set()).add(section)
|
||||
|
||||
return None
|
||||
|
||||
def _publish_updates(self, changed: dict[str, set[str]]) -> None:
|
||||
"""Publish ZMQ config updates only for sections that changed."""
|
||||
for cam_name, sections in changed.items():
|
||||
cam_config = self.config.cameras.get(cam_name)
|
||||
if cam_config is None:
|
||||
continue
|
||||
|
||||
for section in sections:
|
||||
if section == "enabled":
|
||||
self.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.enabled, cam_name
|
||||
),
|
||||
cam_config.enabled,
|
||||
)
|
||||
if self.dispatcher is not None:
|
||||
self.dispatcher.publish(
|
||||
f"{cam_name}/enabled/state",
|
||||
"ON" if cam_config.enabled else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if section == "zones":
|
||||
self.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, cam_name),
|
||||
cam_config.zones,
|
||||
)
|
||||
continue
|
||||
|
||||
update_enum = PROFILE_SECTION_UPDATES.get(section)
|
||||
if update_enum is None:
|
||||
continue
|
||||
settings = getattr(cam_config, section, None)
|
||||
if settings is not None:
|
||||
self.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(update_enum, cam_name),
|
||||
settings,
|
||||
)
|
||||
|
||||
def _persist_active_profile(self, profile_name: Optional[str]) -> None:
|
||||
"""Persist the active profile name to disk."""
|
||||
try:
|
||||
if profile_name is None:
|
||||
PERSISTENCE_FILE.unlink(missing_ok=True)
|
||||
else:
|
||||
PERSISTENCE_FILE.write_text(profile_name)
|
||||
except OSError:
|
||||
logger.exception("Failed to persist active profile")
|
||||
|
||||
@staticmethod
|
||||
def load_persisted_profile() -> Optional[str]:
|
||||
"""Load the persisted active profile name from disk."""
|
||||
try:
|
||||
if PERSISTENCE_FILE.exists():
|
||||
name = PERSISTENCE_FILE.read_text().strip()
|
||||
return name if name else None
|
||||
except OSError:
|
||||
logger.exception("Failed to load persisted profile")
|
||||
return None
|
||||
|
||||
def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]:
|
||||
"""Return base (pre-profile) section configs for a camera.
|
||||
|
||||
These are JSON-serializable dicts suitable for direct inclusion in
|
||||
the /api/config response, with None values already excluded.
|
||||
"""
|
||||
return self._base_api_configs.get(camera_name, {})
|
||||
|
||||
def get_available_profiles(self) -> list[dict[str, str]]:
|
||||
"""Get list of all profile definitions from the top-level config."""
|
||||
return [
|
||||
{"name": name, "friendly_name": defn.friendly_name}
|
||||
for name, defn in sorted(self.config.profiles.items())
|
||||
]
|
||||
|
||||
def get_profile_info(self) -> dict:
|
||||
"""Get profile state info for API responses."""
|
||||
return {
|
||||
"profiles": self.get_available_profiles(),
|
||||
"active_profile": self.config.active_profile,
|
||||
}
|
||||
@ -2,16 +2,29 @@ import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Mock complex imports before importing maintainer
|
||||
sys.modules["frigate.comms.inter_process"] = MagicMock()
|
||||
sys.modules["frigate.comms.detections_updater"] = MagicMock()
|
||||
sys.modules["frigate.comms.recordings_updater"] = MagicMock()
|
||||
sys.modules["frigate.config.camera.updater"] = MagicMock()
|
||||
# Mock complex imports before importing maintainer, saving originals so we can
|
||||
# restore them after import and avoid polluting sys.modules for other tests.
|
||||
_MOCKED_MODULES = [
|
||||
"frigate.comms.inter_process",
|
||||
"frigate.comms.detections_updater",
|
||||
"frigate.comms.recordings_updater",
|
||||
"frigate.config.camera.updater",
|
||||
]
|
||||
_originals = {name: sys.modules.get(name) for name in _MOCKED_MODULES}
|
||||
for name in _MOCKED_MODULES:
|
||||
sys.modules[name] = MagicMock()
|
||||
|
||||
# Now import the class under test
|
||||
from frigate.config import FrigateConfig # noqa: E402
|
||||
from frigate.record.maintainer import RecordingMaintainer # noqa: E402
|
||||
|
||||
# Restore original modules (or remove mock if there was no original)
|
||||
for name, orig in _originals.items():
|
||||
if orig is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = orig
|
||||
|
||||
|
||||
class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_move_files_survives_bad_filename(self):
|
||||
|
||||
629
frigate/test/test_profiles.py
Normal file
629
frigate/test/test_profiles.py
Normal file
@ -0,0 +1,629 @@
|
||||
"""Tests for the profiles system."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.profile import CameraProfileConfig
|
||||
from frigate.config.profile import ProfileDefinitionConfig
|
||||
from frigate.config.profile_manager import PERSISTENCE_FILE, ProfileManager
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
|
||||
|
||||
class TestCameraProfileConfig(unittest.TestCase):
|
||||
"""Test the CameraProfileConfig Pydantic model."""
|
||||
|
||||
def test_empty_profile(self):
|
||||
"""All sections default to None."""
|
||||
profile = CameraProfileConfig()
|
||||
assert profile.detect is None
|
||||
assert profile.motion is None
|
||||
assert profile.objects is None
|
||||
assert profile.review is None
|
||||
assert profile.notifications is None
|
||||
|
||||
def test_partial_detect(self):
|
||||
"""Profile with only detect.enabled set."""
|
||||
profile = CameraProfileConfig(detect={"enabled": False})
|
||||
assert profile.detect is not None
|
||||
assert profile.detect.enabled is False
|
||||
dumped = profile.detect.model_dump(exclude_unset=True)
|
||||
assert dumped == {"enabled": False}
|
||||
|
||||
def test_partial_notifications(self):
|
||||
"""Profile with only notifications.enabled set."""
|
||||
profile = CameraProfileConfig(notifications={"enabled": True})
|
||||
assert profile.notifications is not None
|
||||
assert profile.notifications.enabled is True
|
||||
dumped = profile.notifications.model_dump(exclude_unset=True)
|
||||
assert dumped == {"enabled": True}
|
||||
|
||||
def test_partial_objects(self):
|
||||
"""Profile with objects.track set."""
|
||||
profile = CameraProfileConfig(objects={"track": ["car", "package"]})
|
||||
assert profile.objects is not None
|
||||
assert profile.objects.track == ["car", "package"]
|
||||
|
||||
def test_partial_review(self):
|
||||
"""Profile with nested review.alerts.labels."""
|
||||
profile = CameraProfileConfig(review={"alerts": {"labels": ["person", "car"]}})
|
||||
assert profile.review is not None
|
||||
assert profile.review.alerts.labels == ["person", "car"]
|
||||
|
||||
def test_enabled_field(self):
|
||||
"""Profile with enabled set to False."""
|
||||
profile = CameraProfileConfig(enabled=False)
|
||||
assert profile.enabled is False
|
||||
dumped = profile.model_dump(exclude_unset=True)
|
||||
assert dumped == {"enabled": False}
|
||||
|
||||
def test_enabled_field_true(self):
|
||||
"""Profile with enabled set to True."""
|
||||
profile = CameraProfileConfig(enabled=True)
|
||||
assert profile.enabled is True
|
||||
|
||||
def test_enabled_default_none(self):
|
||||
"""Enabled defaults to None when not set."""
|
||||
profile = CameraProfileConfig()
|
||||
assert profile.enabled is None
|
||||
|
||||
def test_zones_field(self):
|
||||
"""Profile with zones override."""
|
||||
profile = CameraProfileConfig(
|
||||
zones={
|
||||
"driveway": {
|
||||
"coordinates": "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9",
|
||||
"objects": ["car"],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert profile.zones is not None
|
||||
assert "driveway" in profile.zones
|
||||
|
||||
def test_zones_default_none(self):
|
||||
"""Zones defaults to None when not set."""
|
||||
profile = CameraProfileConfig()
|
||||
assert profile.zones is None
|
||||
|
||||
def test_none_sections_not_in_dump(self):
|
||||
"""Sections left as None should not appear in exclude_unset dump."""
|
||||
profile = CameraProfileConfig(detect={"enabled": False})
|
||||
dumped = profile.model_dump(exclude_unset=True)
|
||||
assert "detect" in dumped
|
||||
assert "motion" not in dumped
|
||||
assert "objects" not in dumped
|
||||
|
||||
def test_invalid_field_value_rejected(self):
|
||||
"""Invalid field values are caught by Pydantic."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
CameraProfileConfig(detect={"fps": "not_a_number"})
|
||||
|
||||
def test_invalid_section_key_rejected(self):
|
||||
"""Unknown section keys are rejected (extra=forbid from FrigateBaseModel)."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
CameraProfileConfig(ffmpeg={"inputs": []})
|
||||
|
||||
def test_invalid_nested_field_rejected(self):
|
||||
"""Invalid nested field values are caught."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
CameraProfileConfig(review={"alerts": {"labels": "not_a_list"}})
|
||||
|
||||
def test_invalid_profile_in_camera_config(self):
|
||||
"""Invalid profile section in full config is caught at parse time."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"detect": {"fps": "invalid"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**config_data)
|
||||
|
||||
def test_undefined_profile_reference_rejected(self):
|
||||
"""Camera referencing a profile not defined in top-level profiles is rejected."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"profiles": {
|
||||
"nonexistent": {
|
||||
"detect": {"enabled": False},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**config_data)
|
||||
|
||||
|
||||
class TestProfileInConfig(unittest.TestCase):
|
||||
"""Test that profiles parse correctly in FrigateConfig."""
|
||||
|
||||
def setUp(self):
|
||||
self.base_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
"disarmed": {"friendly_name": "Disarmed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"notifications": {"enabled": True},
|
||||
"objects": {"track": ["person", "car", "package"]},
|
||||
},
|
||||
"disarmed": {
|
||||
"notifications": {"enabled": False},
|
||||
"objects": {"track": ["package"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.2:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"detect": {"enabled": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR):
|
||||
os.makedirs(MODEL_CACHE_DIR)
|
||||
|
||||
def test_profiles_parse(self):
|
||||
"""Profiles are parsed into Dict[str, CameraProfileConfig]."""
|
||||
config = FrigateConfig(**self.base_config)
|
||||
front = config.cameras["front"]
|
||||
assert "armed" in front.profiles
|
||||
assert "disarmed" in front.profiles
|
||||
assert isinstance(front.profiles["armed"], CameraProfileConfig)
|
||||
|
||||
def test_profile_sections_parsed(self):
|
||||
"""Profile sections are properly typed."""
|
||||
config = FrigateConfig(**self.base_config)
|
||||
armed = config.cameras["front"].profiles["armed"]
|
||||
assert armed.notifications is not None
|
||||
assert armed.notifications.enabled is True
|
||||
assert armed.objects is not None
|
||||
assert armed.objects.track == ["person", "car", "package"]
|
||||
assert armed.detect is None # not set in this profile
|
||||
|
||||
def test_camera_without_profiles(self):
|
||||
"""Camera with no profiles has empty dict."""
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
config = FrigateConfig(**config_data)
|
||||
assert config.cameras["front"].profiles == {}
|
||||
|
||||
|
||||
class TestProfileManager(unittest.TestCase):
|
||||
"""Test ProfileManager activation, deactivation, and switching."""
|
||||
|
||||
def setUp(self):
|
||||
self.config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
"disarmed": {"friendly_name": "Disarmed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"notifications": {"enabled": False},
|
||||
"objects": {"track": ["person"]},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"notifications": {"enabled": True},
|
||||
"objects": {"track": ["person", "car", "package"]},
|
||||
},
|
||||
"disarmed": {
|
||||
"notifications": {"enabled": False},
|
||||
"objects": {"track": ["package"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.2:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"notifications": {"enabled": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR):
|
||||
os.makedirs(MODEL_CACHE_DIR)
|
||||
|
||||
self.config = FrigateConfig(**self.config_data)
|
||||
self.mock_updater = MagicMock()
|
||||
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||
|
||||
def test_get_available_profiles(self):
|
||||
"""Available profiles come from top-level profile definitions."""
|
||||
profiles = self.manager.get_available_profiles()
|
||||
assert len(profiles) == 2
|
||||
names = [p["name"] for p in profiles]
|
||||
assert "armed" in names
|
||||
assert "disarmed" in names
|
||||
# Verify friendly_name is included
|
||||
armed = next(p for p in profiles if p["name"] == "armed")
|
||||
assert armed["friendly_name"] == "Armed"
|
||||
|
||||
def test_activate_invalid_profile(self):
|
||||
"""Activating non-existent profile returns error."""
|
||||
err = self.manager.activate_profile("nonexistent")
|
||||
assert err is not None
|
||||
assert "not defined" in err
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_activate_profile(self, mock_persist):
|
||||
"""Activating a profile applies overrides."""
|
||||
err = self.manager.activate_profile("armed")
|
||||
assert err is None
|
||||
assert self.config.active_profile == "armed"
|
||||
|
||||
# Front camera should have armed overrides
|
||||
front = self.config.cameras["front"]
|
||||
assert front.notifications.enabled is True
|
||||
assert front.objects.track == ["person", "car", "package"]
|
||||
|
||||
# Back camera should have armed overrides
|
||||
back = self.config.cameras["back"]
|
||||
assert back.notifications.enabled is True
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_deactivate_profile(self, mock_persist):
|
||||
"""Deactivating a profile restores base config."""
|
||||
# Activate first
|
||||
self.manager.activate_profile("armed")
|
||||
assert self.config.cameras["front"].notifications.enabled is True
|
||||
|
||||
# Deactivate
|
||||
err = self.manager.activate_profile(None)
|
||||
assert err is None
|
||||
assert self.config.active_profile is None
|
||||
|
||||
# Should be back to base
|
||||
front = self.config.cameras["front"]
|
||||
assert front.notifications.enabled is False
|
||||
assert front.objects.track == ["person"]
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_switch_profiles(self, mock_persist):
|
||||
"""Switching from one profile to another works."""
|
||||
self.manager.activate_profile("armed")
|
||||
assert self.config.cameras["front"].objects.track == [
|
||||
"person",
|
||||
"car",
|
||||
"package",
|
||||
]
|
||||
|
||||
self.manager.activate_profile("disarmed")
|
||||
assert self.config.active_profile == "disarmed"
|
||||
assert self.config.cameras["front"].objects.track == ["package"]
|
||||
assert self.config.cameras["front"].notifications.enabled is False
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_unaffected_camera(self, mock_persist):
|
||||
"""Camera without the activated profile is unaffected."""
|
||||
back_base_notifications = self.config.cameras["back"].notifications.enabled
|
||||
|
||||
self.manager.activate_profile("disarmed")
|
||||
|
||||
# Back camera has no "disarmed" profile, should be unchanged
|
||||
assert (
|
||||
self.config.cameras["back"].notifications.enabled == back_base_notifications
|
||||
)
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_activate_profile_disables_camera(self, mock_persist):
|
||||
"""Profile with enabled=false disables the camera."""
|
||||
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
||||
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
||||
enabled=False
|
||||
)
|
||||
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||
|
||||
assert self.config.cameras["front"].enabled is True
|
||||
err = self.manager.activate_profile("away")
|
||||
assert err is None
|
||||
assert self.config.cameras["front"].enabled is False
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_deactivate_restores_enabled(self, mock_persist):
|
||||
"""Deactivating a profile restores the camera's base enabled state."""
|
||||
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
||||
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
||||
enabled=False
|
||||
)
|
||||
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||
|
||||
self.manager.activate_profile("away")
|
||||
assert self.config.cameras["front"].enabled is False
|
||||
|
||||
self.manager.activate_profile(None)
|
||||
assert self.config.cameras["front"].enabled is True
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_activate_profile_adds_zone(self, mock_persist):
|
||||
"""Profile with zones adds/overrides zones on camera."""
|
||||
from frigate.config.camera.zone import ZoneConfig
|
||||
|
||||
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
||||
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
||||
zones={
|
||||
"driveway": ZoneConfig(
|
||||
coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9",
|
||||
objects=["car"],
|
||||
)
|
||||
}
|
||||
)
|
||||
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||
|
||||
assert "driveway" not in self.config.cameras["front"].zones
|
||||
|
||||
err = self.manager.activate_profile("away")
|
||||
assert err is None
|
||||
assert "driveway" in self.config.cameras["front"].zones
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_deactivate_restores_zones(self, mock_persist):
|
||||
"""Deactivating a profile restores base zones."""
|
||||
from frigate.config.camera.zone import ZoneConfig
|
||||
|
||||
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
||||
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
||||
zones={
|
||||
"driveway": ZoneConfig(
|
||||
coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9",
|
||||
objects=["car"],
|
||||
)
|
||||
}
|
||||
)
|
||||
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||
|
||||
self.manager.activate_profile("away")
|
||||
assert "driveway" in self.config.cameras["front"].zones
|
||||
|
||||
self.manager.activate_profile(None)
|
||||
assert "driveway" not in self.config.cameras["front"].zones
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_zones_zmq_published(self, mock_persist):
|
||||
"""ZMQ update is published for zones change."""
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.camera.zone import ZoneConfig
|
||||
|
||||
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
||||
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
||||
zones={
|
||||
"driveway": ZoneConfig(
|
||||
coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9",
|
||||
objects=["car"],
|
||||
)
|
||||
}
|
||||
)
|
||||
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||
self.mock_updater.reset_mock()
|
||||
|
||||
self.manager.activate_profile("away")
|
||||
|
||||
zones_calls = [
|
||||
call
|
||||
for call in self.mock_updater.publish_update.call_args_list
|
||||
if call[0][0]
|
||||
== CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, "front")
|
||||
]
|
||||
assert len(zones_calls) == 1
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_enabled_zmq_published(self, mock_persist):
|
||||
"""ZMQ update is published for enabled state change."""
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
|
||||
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
||||
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
||||
enabled=False
|
||||
)
|
||||
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||
self.mock_updater.reset_mock()
|
||||
|
||||
self.manager.activate_profile("away")
|
||||
|
||||
# Find the enabled update call
|
||||
enabled_calls = [
|
||||
call
|
||||
for call in self.mock_updater.publish_update.call_args_list
|
||||
if call[0][0]
|
||||
== CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, "front")
|
||||
]
|
||||
assert len(enabled_calls) == 1
|
||||
assert enabled_calls[0][0][1] is False
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_zmq_updates_published(self, mock_persist):
|
||||
"""ZMQ updates are published when a profile is activated."""
|
||||
self.manager.activate_profile("armed")
|
||||
assert self.mock_updater.publish_update.called
|
||||
|
||||
def test_get_profile_info(self):
|
||||
"""Profile info returns correct structure with friendly names."""
|
||||
info = self.manager.get_profile_info()
|
||||
assert "profiles" in info
|
||||
assert "active_profile" in info
|
||||
assert info["active_profile"] is None
|
||||
names = [p["name"] for p in info["profiles"]]
|
||||
assert "armed" in names
|
||||
assert "disarmed" in names
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_base_configs_for_api_unchanged_after_activation(self, mock_persist):
|
||||
"""API base configs reflect pre-profile values after activation."""
|
||||
base_track = self.config.cameras["front"].objects.track[:]
|
||||
assert base_track == ["person"]
|
||||
|
||||
self.manager.activate_profile("armed")
|
||||
|
||||
# In-memory config has the profile-merged values
|
||||
assert self.config.cameras["front"].objects.track == [
|
||||
"person",
|
||||
"car",
|
||||
"package",
|
||||
]
|
||||
|
||||
# But the API base configs still return the original base values
|
||||
api_base = self.manager.get_base_configs_for_api("front")
|
||||
assert "objects" in api_base
|
||||
assert api_base["objects"]["track"] == ["person"]
|
||||
|
||||
def test_base_configs_for_api_are_json_serializable(self):
|
||||
"""API base configs are JSON-serializable (mode='json')."""
|
||||
import json
|
||||
|
||||
api_base = self.manager.get_base_configs_for_api("front")
|
||||
# Should not raise
|
||||
json.dumps(api_base)
|
||||
|
||||
|
||||
class TestProfilePersistence(unittest.TestCase):
|
||||
"""Test profile persistence to disk."""
|
||||
|
||||
def test_persist_and_load(self):
|
||||
"""Active profile name can be persisted and loaded."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(temp_path)
|
||||
path.write_text("armed")
|
||||
loaded = path.read_text().strip()
|
||||
assert loaded == "armed"
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_load_empty_file(self):
|
||||
"""Empty persistence file returns None."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True):
|
||||
with patch.object(type(PERSISTENCE_FILE), "read_text", return_value=""):
|
||||
result = ProfileManager.load_persisted_profile()
|
||||
assert result is None
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_load_missing_file(self):
|
||||
"""Missing persistence file returns None."""
|
||||
with patch.object(type(PERSISTENCE_FILE), "exists", return_value=False):
|
||||
result = ProfileManager.load_persisted_profile()
|
||||
assert result is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -717,7 +717,7 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
|
||||
|
||||
if section == "motion":
|
||||
merged = deep_merge(
|
||||
current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}),
|
||||
current.model_dump(exclude_unset=True),
|
||||
update,
|
||||
override=True,
|
||||
)
|
||||
@ -727,9 +727,7 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
|
||||
|
||||
elif section == "objects":
|
||||
merged = deep_merge(
|
||||
current.model_dump(
|
||||
exclude={"filters": {"__all__": {"rasterized_mask"}}}
|
||||
),
|
||||
current.model_dump(),
|
||||
update,
|
||||
override=True,
|
||||
)
|
||||
|
||||
@ -168,6 +168,7 @@
|
||||
"systemMetrics": "System metrics",
|
||||
"configuration": "Configuration",
|
||||
"systemLogs": "System logs",
|
||||
"profiles": "Profiles",
|
||||
"settings": "Settings",
|
||||
"configurationEditor": "Configuration Editor",
|
||||
"languages": "Languages",
|
||||
|
||||
@ -8,12 +8,19 @@
|
||||
"masksAndZones": "Mask and Zone Editor - Frigate",
|
||||
"motionTuner": "Motion Tuner - Frigate",
|
||||
"object": "Debug - Frigate",
|
||||
"general": "Profile Settings - Frigate",
|
||||
"general": "UI Settings - Frigate",
|
||||
"globalConfig": "Global Configuration - Frigate",
|
||||
"cameraConfig": "Camera Configuration - Frigate",
|
||||
"frigatePlus": "Frigate+ Settings - Frigate",
|
||||
"notifications": "Notification Settings - Frigate",
|
||||
"maintenance": "Maintenance - Frigate"
|
||||
"maintenance": "Maintenance - Frigate",
|
||||
"profiles": "Profiles - Frigate"
|
||||
},
|
||||
"button": {
|
||||
"overriddenGlobal": "Overridden (Global)",
|
||||
"overriddenGlobalTooltip": "This camera overrides global configuration settings in this section",
|
||||
"overriddenBaseConfig": "Overridden (Base Config)",
|
||||
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section"
|
||||
},
|
||||
"menu": {
|
||||
"general": "General",
|
||||
@ -22,7 +29,8 @@
|
||||
"integrations": "Integrations",
|
||||
"cameras": "Camera configuration",
|
||||
"ui": "UI",
|
||||
"profileSettings": "Profile settings",
|
||||
"uiSettings": "UI settings",
|
||||
"profiles": "Profiles",
|
||||
"globalDetect": "Object detection",
|
||||
"globalRecording": "Recording",
|
||||
"globalSnapshots": "Snapshots",
|
||||
@ -101,6 +109,9 @@
|
||||
"global": "Global",
|
||||
"camera": "Camera: {{cameraName}}"
|
||||
},
|
||||
"profile": {
|
||||
"label": "Profile"
|
||||
},
|
||||
"field": {
|
||||
"label": "Field"
|
||||
},
|
||||
@ -114,7 +125,7 @@
|
||||
"noCamera": "No Camera"
|
||||
},
|
||||
"general": {
|
||||
"title": "Profile Settings",
|
||||
"title": "UI Settings",
|
||||
"liveDashboard": {
|
||||
"title": "Live Dashboard",
|
||||
"automaticLiveView": {
|
||||
@ -473,6 +484,14 @@
|
||||
"toast": {
|
||||
"success": "Camera {{cameraName}} saved successfully"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Profile Camera Overrides",
|
||||
"selectLabel": "Select profile",
|
||||
"description": "Configure which cameras are enabled or disabled when a profile is activated. Cameras set to \"Inherit\" keep their base enabled state.",
|
||||
"inherit": "Inherit",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
}
|
||||
},
|
||||
"cameraReview": {
|
||||
@ -519,6 +538,8 @@
|
||||
},
|
||||
"restart_required": "Restart required (masks/zones changed)",
|
||||
"disabledInConfig": "Item is disabled in the config file",
|
||||
"profileBase": "(base)",
|
||||
"profileOverride": "(override)",
|
||||
"toast": {
|
||||
"success": {
|
||||
"copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."
|
||||
@ -1327,7 +1348,8 @@
|
||||
"genai": "GenAI",
|
||||
"face_recognition": "Face Recognition",
|
||||
"lpr": "License Plate Recognition",
|
||||
"birdseye": "Birdseye"
|
||||
"birdseye": "Birdseye",
|
||||
"masksAndZones": "Masks / Zones"
|
||||
},
|
||||
"detect": {
|
||||
"title": "Detection Settings"
|
||||
@ -1427,6 +1449,46 @@
|
||||
"saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.",
|
||||
"saveAllFailure": "Failed to save all sections."
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Profiles",
|
||||
"activeProfile": "Active Profile",
|
||||
"noActiveProfile": "No active profile",
|
||||
"active": "Active",
|
||||
"activated": "Profile '{{profile}}' activated",
|
||||
"activateFailed": "Failed to set profile",
|
||||
"deactivated": "Profile deactivated",
|
||||
"noProfiles": "No profiles defined.",
|
||||
"noOverrides": "No overrides",
|
||||
"cameraCount_one": "{{count}} camera",
|
||||
"cameraCount_other": "{{count}} cameras",
|
||||
"baseConfig": "Base Config",
|
||||
"addProfile": "Add Profile",
|
||||
"newProfile": "New Profile",
|
||||
"profileNamePlaceholder": "e.g., Armed, Away, Night Mode",
|
||||
"friendlyNameLabel": "Profile Name",
|
||||
"profileIdLabel": "Profile ID",
|
||||
"profileIdDescription": "Internal identifier used in config and automations",
|
||||
"nameInvalid": "Only lowercase letters, numbers, and underscores allowed",
|
||||
"nameDuplicate": "A profile with this name already exists",
|
||||
"error": {
|
||||
"mustBeAtLeastTwoCharacters": "Must be at least 2 characters",
|
||||
"mustNotContainPeriod": "Must not contain periods",
|
||||
"alreadyExists": "A profile with this ID already exists"
|
||||
},
|
||||
"renameProfile": "Rename Profile",
|
||||
"renameSuccess": "Profile renamed to '{{profile}}'",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.",
|
||||
"deleteSuccess": "Profile '{{profile}}' deleted",
|
||||
"createSuccess": "Profile '{{profile}}' created",
|
||||
"removeOverride": "Remove Profile Override",
|
||||
"deleteSection": "Delete Section Overrides",
|
||||
"deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?",
|
||||
"deleteSectionSuccess": "Removed {{section}} overrides for {{profile}}",
|
||||
"enableSwitch": "Enable Profiles",
|
||||
"enabledDescription": "Profiles are enabled. Create a new profile below, navigate to a camera config section to make your changes, and save for changes to take effect.",
|
||||
"disabledDescription": "Profiles allow you to define named sets of camera config overrides (e.g., armed, away, night) that can be activated on demand."
|
||||
},
|
||||
"unsavedChanges": "You have unsaved changes",
|
||||
"confirmReset": "Confirm Reset",
|
||||
"resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.",
|
||||
|
||||
@ -4,8 +4,12 @@ import {
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-provider";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { FaCheck } from "react-icons/fa";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
@ -46,6 +50,21 @@ export default function Statusbar() {
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
const activeProfile = useMemo(() => {
|
||||
if (!profilesData?.active_profile || !profilesData.profiles) return null;
|
||||
const info = profilesData.profiles.find(
|
||||
(p) => p.name === profilesData.active_profile,
|
||||
);
|
||||
const allNames = profilesData.profiles.map((p) => p.name).sort();
|
||||
return {
|
||||
name: profilesData.active_profile,
|
||||
friendlyName: info?.friendly_name ?? profilesData.active_profile,
|
||||
color: getProfileColor(profilesData.active_profile, allNames),
|
||||
};
|
||||
}, [profilesData]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
@ -136,6 +155,21 @@ export default function Statusbar() {
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{activeProfile && (
|
||||
<Link to="/settings?page=profiles">
|
||||
<div className="flex cursor-pointer items-center gap-2 text-sm hover:underline">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 shrink-0 rounded-full",
|
||||
activeProfile.color.dot,
|
||||
)}
|
||||
/>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{activeProfile.friendlyName}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
|
||||
{Object.entries(messages).length === 0 ? (
|
||||
|
||||
@ -28,12 +28,18 @@ import { useConfigOverride } from "@/hooks/use-config-override";
|
||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import get from "lodash/get";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import merge from "lodash/merge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@ -59,6 +65,7 @@ import {
|
||||
globalCameraDefaultSections,
|
||||
buildOverrides,
|
||||
buildConfigDataForPath,
|
||||
getBaseCameraSectionValue,
|
||||
sanitizeSectionData as sharedSanitizeSectionData,
|
||||
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
|
||||
} from "@/utils/configUtil";
|
||||
@ -126,16 +133,25 @@ export interface BaseSectionProps {
|
||||
onStatusChange?: (status: {
|
||||
hasChanges: boolean;
|
||||
isOverridden: boolean;
|
||||
overrideSource?: "global" | "profile";
|
||||
hasValidationErrors: boolean;
|
||||
}) => void;
|
||||
/** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */
|
||||
pendingDataBySection?: Record<string, unknown>;
|
||||
pendingDataBySection?: Record<string, ConfigSectionData>;
|
||||
/** Callback to update pending data for a section */
|
||||
onPendingDataChange?: (
|
||||
sectionKey: string,
|
||||
cameraName: string | undefined,
|
||||
data: ConfigSectionData | null,
|
||||
) => void;
|
||||
/** When set, editing this profile's overrides instead of the base config */
|
||||
profileName?: string;
|
||||
/** Display name for the profile (friendly name) */
|
||||
profileFriendlyName?: string;
|
||||
/** Border color class for profile override badge (e.g., "border-amber-500") */
|
||||
profileBorderColor?: string;
|
||||
/** Callback to delete the current profile's overrides for this section */
|
||||
onDeleteProfileSection?: () => void;
|
||||
}
|
||||
|
||||
export interface CreateSectionOptions {
|
||||
@ -166,6 +182,10 @@ export function ConfigSection({
|
||||
onStatusChange,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
profileName,
|
||||
profileFriendlyName,
|
||||
profileBorderColor,
|
||||
onDeleteProfileSection,
|
||||
}: ConfigSectionProps) {
|
||||
// For replay level, treat as camera-level config access
|
||||
const effectiveLevel = level === "replay" ? "camera" : level;
|
||||
@ -181,12 +201,17 @@ export function ConfigSection({
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
|
||||
// Create a key for this section's pending data
|
||||
// When editing a profile, use "cameraName::profiles.profileName.sectionPath"
|
||||
const effectiveSectionPath = profileName
|
||||
? `profiles.${profileName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
|
||||
const pendingDataKey = useMemo(
|
||||
() =>
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `${cameraName}::${sectionPath}`
|
||||
: sectionPath,
|
||||
[effectiveLevel, cameraName, sectionPath],
|
||||
? `${cameraName}::${effectiveSectionPath}`
|
||||
: effectiveSectionPath,
|
||||
[effectiveLevel, cameraName, effectiveSectionPath],
|
||||
);
|
||||
|
||||
// Use pending data from parent if available, otherwise use local state
|
||||
@ -213,25 +238,29 @@ export function ConfigSection({
|
||||
const setPendingData = useCallback(
|
||||
(data: ConfigSectionData | null) => {
|
||||
if (onPendingDataChange) {
|
||||
onPendingDataChange(sectionPath, cameraName, data);
|
||||
onPendingDataChange(effectiveSectionPath, cameraName, data);
|
||||
} else {
|
||||
setLocalPendingData(data);
|
||||
}
|
||||
},
|
||||
[onPendingDataChange, sectionPath, cameraName],
|
||||
[onPendingDataChange, effectiveSectionPath, cameraName],
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||
const [extraHasChanges, setExtraHasChanges] = useState(false);
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||
const [isDeleteProfileDialogOpen, setIsDeleteProfileDialogOpen] =
|
||||
useState(false);
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const isResettingRef = useRef(false);
|
||||
const isInitializingRef = useRef(true);
|
||||
const lastPendingDataKeyRef = useRef<string | null>(null);
|
||||
|
||||
const updateTopic =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
// Profile definitions don't hot-reload — only PUT /api/profile/set applies them
|
||||
const updateTopic = profileName
|
||||
? undefined
|
||||
: effectiveLevel === "camera" && cameraName
|
||||
? cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: undefined
|
||||
@ -256,7 +285,7 @@ export function ConfigSection({
|
||||
[sectionPath, level, sectionSchema],
|
||||
);
|
||||
|
||||
// Get override status
|
||||
// Get override status (camera vs global)
|
||||
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
|
||||
config,
|
||||
cameraName: effectiveLevel === "camera" ? cameraName : undefined,
|
||||
@ -264,16 +293,46 @@ export function ConfigSection({
|
||||
compareFields: sectionConfig.overrideFields,
|
||||
});
|
||||
|
||||
// Check if the active profile overrides the base config for this section
|
||||
const profileOverridesSection = useMemo(() => {
|
||||
if (!profileName || !cameraName || !config) return false;
|
||||
const profileData = config.cameras?.[cameraName]?.profiles?.[profileName];
|
||||
return !!profileData?.[sectionPath as keyof typeof profileData];
|
||||
}, [profileName, cameraName, config, sectionPath]);
|
||||
|
||||
const overrideSource: "global" | "profile" | undefined =
|
||||
profileOverridesSection ? "profile" : isOverridden ? "global" : undefined;
|
||||
|
||||
// Get current form data
|
||||
// When a profile is active the top-level camera sections contain the
|
||||
// effective (profile-merged) values. For the base-config view we read
|
||||
// from `base_config` (original values before the profile was applied).
|
||||
// When editing a profile, we merge the base value with profile overrides.
|
||||
const rawSectionValue = useMemo(() => {
|
||||
if (!config) return undefined;
|
||||
|
||||
if (effectiveLevel === "camera" && cameraName) {
|
||||
return get(config.cameras?.[cameraName], sectionPath);
|
||||
// Base value: prefer base_config (pre-profile) over effective value
|
||||
const baseValue = getBaseCameraSectionValue(
|
||||
config,
|
||||
cameraName,
|
||||
sectionPath,
|
||||
);
|
||||
if (profileName) {
|
||||
const profileOverrides = get(
|
||||
config.cameras?.[cameraName],
|
||||
`profiles.${profileName}.${sectionPath}`,
|
||||
);
|
||||
if (profileOverrides && typeof profileOverrides === "object") {
|
||||
return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides));
|
||||
}
|
||||
return baseValue;
|
||||
}
|
||||
return baseValue;
|
||||
}
|
||||
|
||||
return get(config, sectionPath);
|
||||
}, [config, cameraName, sectionPath, effectiveLevel]);
|
||||
}, [config, cameraName, sectionPath, effectiveLevel, profileName]);
|
||||
|
||||
const rawFormData = useMemo(() => {
|
||||
if (!config) return {};
|
||||
@ -285,10 +344,20 @@ export function ConfigSection({
|
||||
return rawSectionValue;
|
||||
}, [config, rawSectionValue]);
|
||||
|
||||
// When editing a profile, hide fields that require a restart since they
|
||||
// cannot take effect via profile switching alone.
|
||||
const effectiveHiddenFields = useMemo(() => {
|
||||
if (!profileName || !sectionConfig.restartRequired?.length) {
|
||||
return sectionConfig.hiddenFields;
|
||||
}
|
||||
const base = sectionConfig.hiddenFields ?? [];
|
||||
return [...new Set([...base, ...sectionConfig.restartRequired])];
|
||||
}, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]);
|
||||
|
||||
const sanitizeSectionData = useCallback(
|
||||
(data: ConfigSectionData) =>
|
||||
sharedSanitizeSectionData(data, sectionConfig.hiddenFields),
|
||||
[sectionConfig.hiddenFields],
|
||||
sharedSanitizeSectionData(data, effectiveHiddenFields),
|
||||
[effectiveHiddenFields],
|
||||
);
|
||||
|
||||
const formData = useMemo(() => {
|
||||
@ -386,8 +455,20 @@ export function ConfigSection({
|
||||
}, [formData, pendingData, extraHasChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.({ hasChanges, isOverridden, hasValidationErrors });
|
||||
}, [hasChanges, isOverridden, hasValidationErrors, onStatusChange]);
|
||||
onStatusChange?.({
|
||||
hasChanges,
|
||||
isOverridden: profileOverridesSection || isOverridden,
|
||||
overrideSource,
|
||||
hasValidationErrors,
|
||||
});
|
||||
}, [
|
||||
hasChanges,
|
||||
isOverridden,
|
||||
profileOverridesSection,
|
||||
overrideSource,
|
||||
hasValidationErrors,
|
||||
onStatusChange,
|
||||
]);
|
||||
|
||||
// Handle form data change
|
||||
const handleChange = useCallback(
|
||||
@ -499,8 +580,8 @@ export function ConfigSection({
|
||||
try {
|
||||
const basePath =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
? `cameras.${cameraName}.${effectiveSectionPath}`
|
||||
: effectiveSectionPath;
|
||||
const rawData = sanitizeSectionData(rawFormData);
|
||||
const overrides = buildOverrides(
|
||||
pendingData,
|
||||
@ -522,9 +603,11 @@ export function ConfigSection({
|
||||
return;
|
||||
}
|
||||
|
||||
const needsRestart = skipSave
|
||||
? false
|
||||
: requiresRestartForOverrides(sanitizedOverrides);
|
||||
// Profile definition edits never require restart
|
||||
const needsRestart =
|
||||
skipSave || profileName
|
||||
? false
|
||||
: requiresRestartForOverrides(sanitizedOverrides);
|
||||
|
||||
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
|
||||
await axios.put("config/set", {
|
||||
@ -619,6 +702,8 @@ export function ConfigSection({
|
||||
}
|
||||
}, [
|
||||
sectionPath,
|
||||
effectiveSectionPath,
|
||||
profileName,
|
||||
pendingData,
|
||||
effectiveLevel,
|
||||
cameraName,
|
||||
@ -642,8 +727,8 @@ export function ConfigSection({
|
||||
try {
|
||||
const basePath =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
? `cameras.${cameraName}.${effectiveSectionPath}`
|
||||
: effectiveSectionPath;
|
||||
|
||||
const configData = buildConfigDataForPath(basePath, "");
|
||||
|
||||
@ -675,7 +760,7 @@ export function ConfigSection({
|
||||
);
|
||||
}
|
||||
}, [
|
||||
sectionPath,
|
||||
effectiveSectionPath,
|
||||
effectiveLevel,
|
||||
cameraName,
|
||||
requiresRestart,
|
||||
@ -784,7 +869,7 @@ export function ConfigSection({
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={sectionConfig.hiddenFields}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={sectionConfig.uiSchema}
|
||||
@ -823,7 +908,7 @@ export function ConfigSection({
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: sectionConfig.hiddenFields,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
}}
|
||||
@ -855,7 +940,8 @@ export function ConfigSection({
|
||||
{((effectiveLevel === "camera" && isOverridden) ||
|
||||
effectiveLevel === "global") &&
|
||||
!hasChanges &&
|
||||
!skipSave && (
|
||||
!skipSave &&
|
||||
!profileName && (
|
||||
<Button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
variant="outline"
|
||||
@ -873,6 +959,23 @@ export function ConfigSection({
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{profileName &&
|
||||
profileOverridesSection &&
|
||||
!hasChanges &&
|
||||
!skipSave &&
|
||||
onDeleteProfileSection && (
|
||||
<Button
|
||||
onClick={() => setIsDeleteProfileDialogOpen(true)}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
{t("profiles.removeOverride", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Remove Profile Override",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
@ -944,6 +1047,47 @@ export function ConfigSection({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={isDeleteProfileDialogOpen}
|
||||
onOpenChange={setIsDeleteProfileDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("profiles.deleteSection", { ns: "views/settings" })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("profiles.deleteSectionConfirm", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyName ?? profileName,
|
||||
section: t(`${sectionPath}.label`, {
|
||||
ns:
|
||||
effectiveLevel === "camera"
|
||||
? "config/cameras"
|
||||
: "config/global",
|
||||
defaultValue: sectionPath,
|
||||
}),
|
||||
camera: cameraName ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
onDeleteProfileSection?.();
|
||||
setIsDeleteProfileDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -963,13 +1107,32 @@ export function ConfigSection({
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("button.overridden", {
|
||||
ns: "common",
|
||||
defaultValue: "Overridden",
|
||||
})}
|
||||
</Badge>
|
||||
(profileOverridesSection || isOverridden) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})
|
||||
: t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyName ?? profileName,
|
||||
})
|
||||
: t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
@ -1007,16 +1170,40 @@ export function ConfigSection({
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-default border-2 border-selected text-xs text-primary-variant"
|
||||
>
|
||||
{t("button.overridden", {
|
||||
ns: "common",
|
||||
defaultValue: "Overridden",
|
||||
})}
|
||||
</Badge>
|
||||
(profileOverridesSection || isOverridden) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
||||
overrideSource === "profile" && profileBorderColor
|
||||
? profileBorderColor
|
||||
: "border-selected",
|
||||
)}
|
||||
>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})
|
||||
: t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyName ?? profileName,
|
||||
})
|
||||
: t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Badge
|
||||
|
||||
@ -7,41 +7,35 @@ import type { FormContext } from "./SwitchesWidget";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { JsonObject } from "@/types/configForm";
|
||||
|
||||
function extractListenLabels(value: unknown): string[] {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const listenValue = (value as JsonObject).listen;
|
||||
if (Array.isArray(listenValue)) {
|
||||
return listenValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getEnabledAudioLabels(context: FormContext): string[] {
|
||||
let cameraLabels: string[] = [];
|
||||
let globalLabels: string[] = [];
|
||||
let formDataLabels: string[] = [];
|
||||
|
||||
if (context) {
|
||||
// context.cameraValue and context.globalValue should be the entire audio section
|
||||
if (
|
||||
context.cameraValue &&
|
||||
typeof context.cameraValue === "object" &&
|
||||
!Array.isArray(context.cameraValue)
|
||||
) {
|
||||
const listenValue = (context.cameraValue as JsonObject).listen;
|
||||
if (Array.isArray(listenValue)) {
|
||||
cameraLabels = listenValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
cameraLabels = extractListenLabels(context.cameraValue);
|
||||
globalLabels = extractListenLabels(context.globalValue);
|
||||
|
||||
if (
|
||||
context.globalValue &&
|
||||
typeof context.globalValue === "object" &&
|
||||
!Array.isArray(context.globalValue)
|
||||
) {
|
||||
const globalListenValue = (context.globalValue as JsonObject).listen;
|
||||
if (Array.isArray(globalListenValue)) {
|
||||
globalLabels = globalListenValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Include labels from the current form data so that labels added via
|
||||
// profile overrides (or user edits) are always visible as switches.
|
||||
formDataLabels = extractListenLabels(context.formData);
|
||||
}
|
||||
|
||||
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
|
||||
return [...sourceLabels].sort();
|
||||
return [...new Set([...sourceLabels, ...formDataLabels])].sort();
|
||||
}
|
||||
|
||||
function getAudioLabelDisplayName(label: string): string {
|
||||
|
||||
@ -40,43 +40,42 @@ function getLabelmapLabels(context: FormContext): string[] {
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
// Extract track labels from an objects section value.
|
||||
function extractTrackLabels(value: unknown): string[] {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const trackValue = (value as JsonObject).track;
|
||||
if (Array.isArray(trackValue)) {
|
||||
return trackValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build the list of labels for switches (labelmap + configured track list).
|
||||
function getObjectLabels(context: FormContext): string[] {
|
||||
const labelmapLabels = getLabelmapLabels(context);
|
||||
let cameraLabels: string[] = [];
|
||||
let globalLabels: string[] = [];
|
||||
let formDataLabels: string[] = [];
|
||||
|
||||
if (context) {
|
||||
// context.cameraValue and context.globalValue should be the entire objects section
|
||||
if (
|
||||
context.cameraValue &&
|
||||
typeof context.cameraValue === "object" &&
|
||||
!Array.isArray(context.cameraValue)
|
||||
) {
|
||||
const trackValue = (context.cameraValue as JsonObject).track;
|
||||
if (Array.isArray(trackValue)) {
|
||||
cameraLabels = trackValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
cameraLabels = extractTrackLabels(context.cameraValue);
|
||||
globalLabels = extractTrackLabels(context.globalValue);
|
||||
|
||||
if (
|
||||
context.globalValue &&
|
||||
typeof context.globalValue === "object" &&
|
||||
!Array.isArray(context.globalValue)
|
||||
) {
|
||||
const globalTrackValue = (context.globalValue as JsonObject).track;
|
||||
if (Array.isArray(globalTrackValue)) {
|
||||
globalLabels = globalTrackValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Include labels from the current form data so that labels added via
|
||||
// profile overrides (or user edits) are always visible as switches.
|
||||
formDataLabels = extractTrackLabels(context.formData);
|
||||
}
|
||||
|
||||
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
|
||||
const combinedLabels = new Set<string>([...labelmapLabels, ...sourceLabels]);
|
||||
const combinedLabels = new Set<string>([
|
||||
...labelmapLabels,
|
||||
...sourceLabels,
|
||||
...formDataLabels,
|
||||
]);
|
||||
return [...combinedLabels].sort();
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ type FormContext = Pick<
|
||||
| "globalValue"
|
||||
| "fullCameraConfig"
|
||||
| "fullConfig"
|
||||
| "formData"
|
||||
| "t"
|
||||
| "level"
|
||||
> & {
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
LuActivity,
|
||||
LuGithub,
|
||||
LuLanguages,
|
||||
LuLayers,
|
||||
LuLifeBuoy,
|
||||
LuList,
|
||||
LuLogOut,
|
||||
@ -69,6 +70,9 @@ import SetPasswordDialog from "../overlay/SetPasswordDialog";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { supportedLanguageKeys } from "@/lib/const";
|
||||
|
||||
@ -84,6 +88,8 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: profile } = useSWR("profile");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: profilesData, mutate: updateProfiles } =
|
||||
useSWR<ProfilesApiResponse>("profiles");
|
||||
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
||||
|
||||
// languages
|
||||
@ -105,6 +111,41 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
// profiles
|
||||
|
||||
const allProfileNames = useMemo(
|
||||
() => profilesData?.profiles?.map((p) => p.name) ?? [],
|
||||
[profilesData],
|
||||
);
|
||||
|
||||
const profileFriendlyNames = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
profilesData?.profiles?.forEach((p) => map.set(p.name, p.friendly_name));
|
||||
return map;
|
||||
}, [profilesData]);
|
||||
|
||||
const hasProfiles = allProfileNames.length > 0;
|
||||
|
||||
const handleActivateProfile = async (profileName: string | null) => {
|
||||
try {
|
||||
await axios.put("profile/set", { profile: profileName || null });
|
||||
await updateProfiles();
|
||||
toast.success(
|
||||
profileName
|
||||
? t("profiles.activated", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyNames.get(profileName) ?? profileName,
|
||||
})
|
||||
: t("profiles.deactivated", { ns: "views/settings" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("profiles.activateFailed", { ns: "views/settings" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// settings
|
||||
|
||||
const { language, setLanguage } = useLanguage();
|
||||
@ -285,6 +326,118 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>{t("menu.systemLogs")}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
{hasProfiles && (
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuLayers className="mr-2 size-4" />
|
||||
<span>{t("menu.profiles")}</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
{!isDesktop && (
|
||||
<>
|
||||
<DialogTitle className="sr-only">
|
||||
{t("menu.profiles")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t("menu.profiles")}
|
||||
</DialogDescription>
|
||||
</>
|
||||
)}
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label={t("profiles.baseConfig", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
onClick={() => handleActivateProfile(null)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="ml-6 mr-2">
|
||||
{t("profiles.baseConfig", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
{!profilesData?.active_profile && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs text-primary-variant"
|
||||
>
|
||||
{t("profiles.active", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{allProfileNames.map((profileName) => {
|
||||
const color = getProfileColor(
|
||||
profileName,
|
||||
allProfileNames,
|
||||
);
|
||||
const isActive =
|
||||
profilesData?.active_profile === profileName;
|
||||
return (
|
||||
<MenuItem
|
||||
key={profileName}
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label={
|
||||
profileFriendlyNames.get(profileName) ??
|
||||
profileName
|
||||
}
|
||||
onClick={() =>
|
||||
handleActivateProfile(profileName)
|
||||
}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 size-2 shrink-0 rounded-full",
|
||||
color.dot,
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
{profileFriendlyNames.get(profileName) ??
|
||||
profileName}
|
||||
</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs text-primary-variant"
|
||||
>
|
||||
{t("profiles.active", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
|
||||
export type SaveAllPreviewItem = {
|
||||
scope: "global" | "camera";
|
||||
cameraName?: string;
|
||||
profileName?: string;
|
||||
fieldPath: string;
|
||||
value: unknown;
|
||||
};
|
||||
@ -114,6 +115,18 @@ export default function SaveAllPreviewPopover({
|
||||
})}
|
||||
</span>
|
||||
<span className="truncate">{scopeLabel}</span>
|
||||
{item.profileName && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("saveAllPreview.profile.label", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{item.profileName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{t("saveAllPreview.field.label", {
|
||||
ns: "views/settings",
|
||||
|
||||
@ -44,6 +44,7 @@ type MotionMaskEditPaneProps = {
|
||||
onCancel?: () => void;
|
||||
snapPoints: boolean;
|
||||
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editingProfile?: string | null;
|
||||
};
|
||||
|
||||
export default function MotionMaskEditPane({
|
||||
@ -58,6 +59,7 @@ export default function MotionMaskEditPane({
|
||||
onCancel,
|
||||
snapPoints,
|
||||
setSnapPoints,
|
||||
editingProfile,
|
||||
}: MotionMaskEditPaneProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
@ -192,16 +194,28 @@ export default function MotionMaskEditPane({
|
||||
coordinates: coordinates,
|
||||
};
|
||||
|
||||
// Build config path based on profile mode
|
||||
const motionMaskPath = editingProfile
|
||||
? {
|
||||
profiles: {
|
||||
[editingProfile]: {
|
||||
motion: { mask: { [maskId]: maskConfig } },
|
||||
},
|
||||
},
|
||||
}
|
||||
: { motion: { mask: { [maskId]: maskConfig } } };
|
||||
|
||||
// If renaming, we need to delete the old mask first
|
||||
if (renamingMask) {
|
||||
const deleteQueryPath = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.motion.mask.${polygon.name}`
|
||||
: `cameras.${polygon.camera}.motion.mask.${polygon.name}`;
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
await axios.put(`config/set?${deleteQueryPath}`, {
|
||||
requires_restart: 0,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
@ -210,22 +224,20 @@ export default function MotionMaskEditPane({
|
||||
}
|
||||
}
|
||||
|
||||
const updateTopic = editingProfile
|
||||
? undefined
|
||||
: `config/cameras/${polygon.camera}/motion`;
|
||||
|
||||
// Save the new/updated mask using JSON body
|
||||
axios
|
||||
.put("config/set", {
|
||||
config_data: {
|
||||
cameras: {
|
||||
[polygon.camera]: {
|
||||
motion: {
|
||||
mask: {
|
||||
[maskId]: maskConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
[polygon.camera]: motionMaskPath,
|
||||
},
|
||||
},
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/motion`,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
@ -238,8 +250,10 @@ export default function MotionMaskEditPane({
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
// Publish the enabled state through websocket
|
||||
sendMotionMaskState(enabled ? "ON" : "OFF");
|
||||
// Only publish WS state for base config
|
||||
if (!editingProfile) {
|
||||
sendMotionMaskState(enabled ? "ON" : "OFF");
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
@ -277,6 +291,7 @@ export default function MotionMaskEditPane({
|
||||
cameraConfig,
|
||||
t,
|
||||
sendMotionMaskState,
|
||||
editingProfile,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -51,6 +51,7 @@ type ObjectMaskEditPaneProps = {
|
||||
onCancel?: () => void;
|
||||
snapPoints: boolean;
|
||||
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editingProfile?: string | null;
|
||||
};
|
||||
|
||||
export default function ObjectMaskEditPane({
|
||||
@ -65,6 +66,7 @@ export default function ObjectMaskEditPane({
|
||||
onCancel,
|
||||
snapPoints,
|
||||
setSnapPoints,
|
||||
editingProfile,
|
||||
}: ObjectMaskEditPaneProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config, mutate: updateConfig } =
|
||||
@ -190,14 +192,22 @@ export default function ObjectMaskEditPane({
|
||||
// Determine if old mask was global or per-object
|
||||
const wasGlobal =
|
||||
polygon.objects.length === 0 || polygon.objects[0] === "all_labels";
|
||||
const oldPath = wasGlobal
|
||||
? `cameras.${polygon.camera}.objects.mask.${polygon.name}`
|
||||
: `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`;
|
||||
|
||||
let oldPath: string;
|
||||
if (editingProfile) {
|
||||
oldPath = wasGlobal
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.objects.mask.${polygon.name}`
|
||||
: `cameras.${polygon.camera}.profiles.${editingProfile}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`;
|
||||
} else {
|
||||
oldPath = wasGlobal
|
||||
? `cameras.${polygon.camera}.objects.mask.${polygon.name}`
|
||||
: `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`;
|
||||
}
|
||||
|
||||
await axios.put(`config/set?${oldPath}`, {
|
||||
requires_restart: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
@ -206,45 +216,32 @@ export default function ObjectMaskEditPane({
|
||||
}
|
||||
}
|
||||
|
||||
// Build the config structure based on whether it's global or per-object
|
||||
let configBody;
|
||||
if (globalMask) {
|
||||
configBody = {
|
||||
config_data: {
|
||||
cameras: {
|
||||
[polygon.camera]: {
|
||||
objects: {
|
||||
mask: {
|
||||
[maskId]: maskConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Build config path based on profile mode
|
||||
const objectsSection = globalMask
|
||||
? { objects: { mask: { [maskId]: maskConfig } } }
|
||||
: {
|
||||
objects: {
|
||||
filters: { [form_objects]: { mask: { [maskId]: maskConfig } } },
|
||||
},
|
||||
};
|
||||
|
||||
const cameraData = editingProfile
|
||||
? { profiles: { [editingProfile]: objectsSection } }
|
||||
: objectsSection;
|
||||
|
||||
const updateTopic = editingProfile
|
||||
? undefined
|
||||
: `config/cameras/${polygon.camera}/objects`;
|
||||
|
||||
const configBody = {
|
||||
config_data: {
|
||||
cameras: {
|
||||
[polygon.camera]: cameraData,
|
||||
},
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/objects`,
|
||||
};
|
||||
} else {
|
||||
configBody = {
|
||||
config_data: {
|
||||
cameras: {
|
||||
[polygon.camera]: {
|
||||
objects: {
|
||||
filters: {
|
||||
[form_objects]: {
|
||||
mask: {
|
||||
[maskId]: maskConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/objects`,
|
||||
};
|
||||
}
|
||||
},
|
||||
requires_restart: 0,
|
||||
update_topic: updateTopic,
|
||||
};
|
||||
|
||||
axios
|
||||
.put("config/set", configBody)
|
||||
@ -259,8 +256,10 @@ export default function ObjectMaskEditPane({
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
// Publish the enabled state through websocket
|
||||
sendObjectMaskState(enabled ? "ON" : "OFF");
|
||||
// Only publish WS state for base config
|
||||
if (!editingProfile) {
|
||||
sendObjectMaskState(enabled ? "ON" : "OFF");
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
@ -301,6 +300,7 @@ export default function ObjectMaskEditPane({
|
||||
cameraConfig,
|
||||
t,
|
||||
sendObjectMaskState,
|
||||
editingProfile,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
|
||||
type PolygonItemProps = {
|
||||
polygon: Polygon;
|
||||
@ -48,6 +49,8 @@ type PolygonItemProps = {
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
loadingPolygonIndex: number | undefined;
|
||||
setLoadingPolygonIndex: (index: number | undefined) => void;
|
||||
editingProfile?: string | null;
|
||||
allProfileNames?: string[];
|
||||
};
|
||||
|
||||
export default function PolygonItem({
|
||||
@ -62,6 +65,8 @@ export default function PolygonItem({
|
||||
setIsLoading,
|
||||
loadingPolygonIndex,
|
||||
setLoadingPolygonIndex,
|
||||
editingProfile,
|
||||
allProfileNames,
|
||||
}: PolygonItemProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config, mutate: updateConfig } =
|
||||
@ -107,6 +112,8 @@ export default function PolygonItem({
|
||||
|
||||
const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
|
||||
|
||||
const isBasePolygon = !!editingProfile && polygon.polygonSource === "base";
|
||||
|
||||
const saveToConfig = useCallback(
|
||||
async (polygon: Polygon) => {
|
||||
if (!polygon || !cameraConfig) {
|
||||
@ -122,25 +129,36 @@ export default function PolygonItem({
|
||||
? "objects"
|
||||
: polygon.type;
|
||||
|
||||
const updateTopic = editingProfile
|
||||
? undefined
|
||||
: `config/cameras/${polygon.camera}/${updateTopicType}`;
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadingPolygonIndex(index);
|
||||
|
||||
if (polygon.type === "zone") {
|
||||
// Zones use query string format
|
||||
const { alertQueries, detectionQueries } = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
|
||||
let url: string;
|
||||
|
||||
if (editingProfile) {
|
||||
// Profile mode: just delete the profile zone
|
||||
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
|
||||
} else {
|
||||
// Base mode: handle review queries
|
||||
const { alertQueries, detectionQueries } = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
|
||||
}
|
||||
|
||||
await axios
|
||||
.put(`config/set?${url}`, {
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
@ -178,64 +196,34 @@ export default function PolygonItem({
|
||||
}
|
||||
|
||||
// Motion masks and object masks use JSON body format
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let configUpdate: any = {};
|
||||
|
||||
if (polygon.type === "motion_mask") {
|
||||
// Delete mask from motion.mask dict by setting it to undefined
|
||||
configUpdate = {
|
||||
cameras: {
|
||||
[polygon.camera]: {
|
||||
motion: {
|
||||
mask: {
|
||||
[polygon.name]: null, // Setting to null will delete the key
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (polygon.type === "object_mask") {
|
||||
// Determine if this is a global mask or object-specific mask
|
||||
const isGlobalMask = !polygon.objects.length;
|
||||
|
||||
if (isGlobalMask) {
|
||||
configUpdate = {
|
||||
cameras: {
|
||||
[polygon.camera]: {
|
||||
objects: {
|
||||
mask: {
|
||||
[polygon.name]: null, // Setting to null will delete the key
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
configUpdate = {
|
||||
cameras: {
|
||||
[polygon.camera]: {
|
||||
const deleteSection =
|
||||
polygon.type === "motion_mask"
|
||||
? { motion: { mask: { [polygon.name]: null } } }
|
||||
: !polygon.objects.length
|
||||
? { objects: { mask: { [polygon.name]: null } } }
|
||||
: {
|
||||
objects: {
|
||||
filters: {
|
||||
[polygon.objects[0]]: {
|
||||
mask: {
|
||||
[polygon.name]: null, // Setting to null will delete the key
|
||||
},
|
||||
mask: { [polygon.name]: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const configUpdate = {
|
||||
cameras: {
|
||||
[polygon.camera]: editingProfile
|
||||
? { profiles: { [editingProfile]: deleteSection } }
|
||||
: deleteSection,
|
||||
},
|
||||
};
|
||||
|
||||
await axios
|
||||
.put("config/set", {
|
||||
config_data: configUpdate,
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
@ -278,6 +266,7 @@ export default function PolygonItem({
|
||||
setIsLoading,
|
||||
index,
|
||||
setLoadingPolygonIndex,
|
||||
editingProfile,
|
||||
],
|
||||
);
|
||||
|
||||
@ -289,14 +278,19 @@ export default function PolygonItem({
|
||||
const handleToggleEnabled = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Prevent toggling if disabled in config
|
||||
if (polygon.enabled_in_config === false) {
|
||||
// Prevent toggling if disabled in config or if this is a base polygon in profile mode
|
||||
if (polygon.enabled_in_config === false || isBasePolygon) {
|
||||
return;
|
||||
}
|
||||
if (!polygon) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't toggle via WS in profile mode
|
||||
if (editingProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnabled = isPolygonEnabled;
|
||||
const nextState = isEnabled ? "OFF" : "ON";
|
||||
|
||||
@ -320,6 +314,8 @@ export default function PolygonItem({
|
||||
sendZoneState,
|
||||
sendMotionMaskState,
|
||||
sendObjectMaskState,
|
||||
isBasePolygon,
|
||||
editingProfile,
|
||||
],
|
||||
);
|
||||
|
||||
@ -358,7 +354,12 @@ export default function PolygonItem({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleEnabled}
|
||||
disabled={isLoading || polygon.enabled_in_config === false}
|
||||
disabled={
|
||||
isLoading ||
|
||||
polygon.enabled_in_config === false ||
|
||||
isBasePolygon ||
|
||||
!!editingProfile
|
||||
}
|
||||
className="mr-2 shrink-0 cursor-pointer border-none bg-transparent p-0 transition-opacity hover:opacity-70 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<PolygonItemIcon
|
||||
@ -384,15 +385,37 @@ export default function PolygonItem({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{editingProfile &&
|
||||
(polygon.polygonSource === "profile" ||
|
||||
polygon.polygonSource === "override") &&
|
||||
allProfileNames && (
|
||||
<span
|
||||
className={cn(
|
||||
"mr-1.5 inline-block h-2 w-2 shrink-0 rounded-full",
|
||||
getProfileColor(editingProfile, allProfileNames).dot,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"cursor-default",
|
||||
!isPolygonEnabled && "opacity-60",
|
||||
polygon.enabled_in_config === false && "line-through",
|
||||
isBasePolygon && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{polygon.friendly_name ?? polygon.name}
|
||||
{!isPolygonEnabled && " (disabled)"}
|
||||
{isBasePolygon && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{t("masksAndZones.profileBase", { ns: "views/settings" })}
|
||||
</span>
|
||||
)}
|
||||
{polygon.polygonSource === "override" && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{t("masksAndZones.profileOverride", { ns: "views/settings" })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
@ -459,7 +482,7 @@ export default function PolygonItem({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isBasePolygon}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
@ -531,9 +554,12 @@ export default function PolygonItem({
|
||||
"size-[15px] cursor-pointer",
|
||||
hoveredPolygonIndex === index &&
|
||||
"fill-primary-variant text-primary-variant",
|
||||
isLoading && "cursor-not-allowed opacity-50",
|
||||
(isLoading || isBasePolygon) &&
|
||||
"cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={() => !isLoading && setDeleteDialogOpen(true)}
|
||||
onClick={() =>
|
||||
!isLoading && !isBasePolygon && setDeleteDialogOpen(true)
|
||||
}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
118
web/src/components/settings/ProfileSectionDropdown.tsx
Normal file
118
web/src/components/settings/ProfileSectionDropdown.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { LuLayers } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ProfileSectionDropdownProps = {
|
||||
allProfileNames: string[];
|
||||
profileFriendlyNames: Map<string, string>;
|
||||
editingProfile: string | null;
|
||||
hasProfileData: (profileName: string) => boolean;
|
||||
onSelectProfile: (profileName: string | null) => void;
|
||||
/** When true, show only an icon as the trigger (for mobile) */
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export function ProfileSectionDropdown({
|
||||
allProfileNames,
|
||||
profileFriendlyNames,
|
||||
editingProfile,
|
||||
hasProfileData,
|
||||
onSelectProfile,
|
||||
iconOnly = false,
|
||||
}: ProfileSectionDropdownProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const activeColor = editingProfile
|
||||
? getProfileColor(editingProfile, allProfileNames)
|
||||
: null;
|
||||
|
||||
const editingFriendlyName = editingProfile
|
||||
? (profileFriendlyNames.get(editingProfile) ?? editingProfile)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{iconOnly ? (
|
||||
<Button variant="outline" size="sm">
|
||||
<LuLayers className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className="h-9 gap-2 font-normal">
|
||||
{editingProfile ? (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
activeColor?.dot,
|
||||
)}
|
||||
/>
|
||||
{editingFriendlyName}
|
||||
</>
|
||||
) : (
|
||||
t("profiles.baseConfig", { ns: "views/settings" })
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuItem onClick={() => onSelectProfile(null)}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{editingProfile === null && (
|
||||
<Check className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className={editingProfile === null ? "" : "pl-[22px]"}>
|
||||
{t("profiles.baseConfig", { ns: "views/settings" })}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{allProfileNames.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{allProfileNames.map((profile) => {
|
||||
const color = getProfileColor(profile, allProfileNames);
|
||||
const hasData = hasProfileData(profile);
|
||||
const isActive = editingProfile === profile;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={profile}
|
||||
className="group flex items-start justify-between gap-2"
|
||||
onClick={() => onSelectProfile(profile)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex w-full flex-row items-center justify-start gap-2">
|
||||
{isActive && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
color.dot,
|
||||
!isActive && "ml-[22px]",
|
||||
)}
|
||||
/>
|
||||
<span>{profileFriendlyNames.get(profile) ?? profile}</span>
|
||||
</div>
|
||||
{!hasData && (
|
||||
<span className="ml-[22px] text-xs text-muted-foreground">
|
||||
{t("profiles.noOverrides", { ns: "views/settings" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@ -50,6 +50,7 @@ type ZoneEditPaneProps = {
|
||||
setActiveLine: React.Dispatch<React.SetStateAction<number | undefined>>;
|
||||
snapPoints: boolean;
|
||||
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editingProfile?: string | null;
|
||||
};
|
||||
|
||||
export default function ZoneEditPane({
|
||||
@ -65,6 +66,7 @@ export default function ZoneEditPane({
|
||||
setActiveLine,
|
||||
snapPoints,
|
||||
setSnapPoints,
|
||||
editingProfile,
|
||||
}: ZoneEditPaneProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
@ -101,15 +103,23 @@ export default function ZoneEditPane({
|
||||
}, [polygon, config]);
|
||||
|
||||
const [lineA, lineB, lineC, lineD] = useMemo(() => {
|
||||
const distances =
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.distances;
|
||||
if (!polygon?.camera || !polygon?.name || !config) {
|
||||
return [undefined, undefined, undefined, undefined];
|
||||
}
|
||||
|
||||
// Check profile zone first, then base
|
||||
const profileZone = editingProfile
|
||||
? config.cameras[polygon.camera]?.profiles?.[editingProfile]?.zones?.[
|
||||
polygon.name
|
||||
]
|
||||
: undefined;
|
||||
const baseZone = config.cameras[polygon.camera]?.zones[polygon.name];
|
||||
const distances = profileZone?.distances ?? baseZone?.distances;
|
||||
|
||||
return Array.isArray(distances)
|
||||
? distances.map((value) => parseFloat(value) || 0)
|
||||
: [undefined, undefined, undefined, undefined];
|
||||
}, [polygon, config]);
|
||||
}, [polygon, config, editingProfile]);
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
@ -272,6 +282,17 @@ export default function ZoneEditPane({
|
||||
},
|
||||
);
|
||||
|
||||
// Resolve zone data: profile zone takes priority over base
|
||||
const resolvedZoneData = useMemo(() => {
|
||||
if (!polygon?.camera || !polygon?.name || !config) return undefined;
|
||||
const cam = config.cameras[polygon.camera];
|
||||
if (!cam) return undefined;
|
||||
const profileZone = editingProfile
|
||||
? cam.profiles?.[editingProfile]?.zones?.[polygon.name]
|
||||
: undefined;
|
||||
return profileZone ?? cam.zones[polygon.name];
|
||||
}, [polygon, config, editingProfile]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onBlur",
|
||||
@ -279,20 +300,11 @@ export default function ZoneEditPane({
|
||||
name: polygon?.name ?? "",
|
||||
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
|
||||
enabled:
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled !==
|
||||
undefined
|
||||
? config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled
|
||||
resolvedZoneData?.enabled !== undefined
|
||||
? resolvedZoneData.enabled
|
||||
: (polygon?.enabled ?? true),
|
||||
inertia:
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia,
|
||||
loitering_time:
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time,
|
||||
inertia: resolvedZoneData?.inertia,
|
||||
loitering_time: resolvedZoneData?.loitering_time,
|
||||
isFinished: polygon?.isFinished ?? false,
|
||||
objects: polygon?.objects ?? [],
|
||||
speedEstimation: !!(lineA || lineB || lineC || lineD),
|
||||
@ -300,10 +312,7 @@ export default function ZoneEditPane({
|
||||
lineB,
|
||||
lineC,
|
||||
lineD,
|
||||
speed_threshold:
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold,
|
||||
speed_threshold: resolvedZoneData?.speed_threshold,
|
||||
},
|
||||
});
|
||||
|
||||
@ -341,6 +350,16 @@ export default function ZoneEditPane({
|
||||
if (!scaledWidth || !scaledHeight || !polygon) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine config path prefix based on profile mode
|
||||
const pathPrefix = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${zoneName}`
|
||||
: `cameras.${polygon.camera}.zones.${zoneName}`;
|
||||
|
||||
const oldPathPrefix = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`
|
||||
: `cameras.${polygon.camera}.zones.${polygon.name}`;
|
||||
|
||||
let mutatedConfig = config;
|
||||
let alertQueries = "";
|
||||
let detectionQueries = "";
|
||||
@ -349,55 +368,74 @@ export default function ZoneEditPane({
|
||||
|
||||
if (renamingZone) {
|
||||
// rename - delete old zone and replace with new
|
||||
const zoneInAlerts =
|
||||
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
|
||||
false;
|
||||
const zoneInDetections =
|
||||
cameraConfig?.review.detections.required_zones.includes(
|
||||
let renameAlertQueries = "";
|
||||
let renameDetectionQueries = "";
|
||||
|
||||
// Only handle review queries for base config (not profiles)
|
||||
if (!editingProfile) {
|
||||
const zoneInAlerts =
|
||||
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
|
||||
false;
|
||||
const zoneInDetections =
|
||||
cameraConfig?.review.detections.required_zones.includes(
|
||||
polygon.name,
|
||||
) ?? false;
|
||||
|
||||
({
|
||||
alertQueries: renameAlertQueries,
|
||||
detectionQueries: renameDetectionQueries,
|
||||
} = reviewQueries(
|
||||
polygon.name,
|
||||
) ?? false;
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
));
|
||||
|
||||
const {
|
||||
alertQueries: renameAlertQueries,
|
||||
detectionQueries: renameDetectionQueries,
|
||||
} = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
try {
|
||||
await axios.put(
|
||||
`config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`,
|
||||
{
|
||||
// Wait for the config to be updated
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure new zone name is readded to review
|
||||
({ alertQueries, detectionQueries } = reviewQueries(
|
||||
zoneName,
|
||||
zoneInAlerts,
|
||||
zoneInDetections,
|
||||
polygon.camera,
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.alerts
|
||||
.required_zones || [],
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.detections
|
||||
.required_zones || [],
|
||||
));
|
||||
} else {
|
||||
// Profile mode: just delete the old profile zone path
|
||||
try {
|
||||
await axios.put(`config/set?${oldPathPrefix}`, {
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the config to be updated
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch (error) {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
});
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure new zone name is readded to review
|
||||
({ alertQueries, detectionQueries } = reviewQueries(
|
||||
zoneName,
|
||||
zoneInAlerts,
|
||||
zoneInDetections,
|
||||
polygon.camera,
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.alerts
|
||||
.required_zones || [],
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.detections
|
||||
.required_zones || [],
|
||||
));
|
||||
}
|
||||
|
||||
const coordinates = flattenPoints(
|
||||
@ -405,10 +443,7 @@ export default function ZoneEditPane({
|
||||
).join(",");
|
||||
|
||||
let objectQueries = objects
|
||||
.map(
|
||||
(object) =>
|
||||
`&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`,
|
||||
)
|
||||
.map((object) => `&${pathPrefix}.objects=${object}`)
|
||||
.join("");
|
||||
|
||||
const same_objects =
|
||||
@ -419,55 +454,55 @@ export default function ZoneEditPane({
|
||||
|
||||
// deleting objects
|
||||
if (!objectQueries && !same_objects && !renamingZone) {
|
||||
objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`;
|
||||
objectQueries = `&${pathPrefix}.objects`;
|
||||
}
|
||||
|
||||
let inertiaQuery = "";
|
||||
if (inertia) {
|
||||
inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`;
|
||||
inertiaQuery = `&${pathPrefix}.inertia=${inertia}`;
|
||||
}
|
||||
|
||||
let loiteringTimeQuery = "";
|
||||
if (loitering_time >= 0) {
|
||||
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
|
||||
loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`;
|
||||
}
|
||||
|
||||
let distancesQuery = "";
|
||||
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
|
||||
if (speedEstimation) {
|
||||
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`;
|
||||
distancesQuery = `&${pathPrefix}.distances=${distances}`;
|
||||
} else {
|
||||
if (distances != "") {
|
||||
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`;
|
||||
distancesQuery = `&${pathPrefix}.distances`;
|
||||
}
|
||||
}
|
||||
|
||||
let speedThresholdQuery = "";
|
||||
if (speed_threshold >= 0 && speedEstimation) {
|
||||
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`;
|
||||
speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`;
|
||||
} else {
|
||||
if (
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold
|
||||
) {
|
||||
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`;
|
||||
if (resolvedZoneData?.speed_threshold) {
|
||||
speedThresholdQuery = `&${pathPrefix}.speed_threshold`;
|
||||
}
|
||||
}
|
||||
|
||||
let friendlyNameQuery = "";
|
||||
if (friendly_name && friendly_name !== zoneName) {
|
||||
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
|
||||
friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_name)}`;
|
||||
}
|
||||
|
||||
const enabledQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.enabled=${enabled ? "True" : "False"}`;
|
||||
const enabledQuery = `&${pathPrefix}.enabled=${enabled ? "True" : "False"}`;
|
||||
|
||||
const updateTopic = editingProfile
|
||||
? undefined
|
||||
: `config/cameras/${polygon.camera}/zones`;
|
||||
|
||||
axios
|
||||
.put(
|
||||
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
|
||||
`config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||
update_topic: updateTopic,
|
||||
},
|
||||
)
|
||||
.then((res) => {
|
||||
@ -481,8 +516,10 @@ export default function ZoneEditPane({
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
// Publish the enabled state through websocket
|
||||
sendZoneState(enabled ? "ON" : "OFF");
|
||||
// Only publish WS state for base config (not profiles)
|
||||
if (!editingProfile) {
|
||||
sendZoneState(enabled ? "ON" : "OFF");
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
@ -524,6 +561,8 @@ export default function ZoneEditPane({
|
||||
cameraConfig,
|
||||
t,
|
||||
sendZoneState,
|
||||
editingProfile,
|
||||
resolvedZoneData,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import set from "lodash/set";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { JsonObject, JsonValue } from "@/types/configForm";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { getBaseCameraSectionValue } from "@/utils/configUtil";
|
||||
|
||||
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
|
||||
|
||||
@ -144,7 +145,13 @@ export function useConfigOverride({
|
||||
};
|
||||
}
|
||||
|
||||
const cameraValue = get(cameraConfig, sectionPath);
|
||||
// Prefer the base (pre-profile) value so that override detection and
|
||||
// widget context reflect the camera's own config, not profile effects.
|
||||
const cameraValue = getBaseCameraSectionValue(
|
||||
config,
|
||||
cameraName,
|
||||
sectionPath,
|
||||
);
|
||||
|
||||
const normalizedGlobalValue = normalizeConfigValue(globalValue);
|
||||
const normalizedCameraValue = normalizeConfigValue(cameraValue);
|
||||
@ -256,7 +263,9 @@ export function useAllCameraOverrides(
|
||||
|
||||
for (const { key, compareFields } of sectionsToCheck) {
|
||||
const globalValue = normalizeConfigValue(get(config, key));
|
||||
const cameraValue = normalizeConfigValue(get(cameraConfig, key));
|
||||
const cameraValue = normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, key),
|
||||
);
|
||||
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(globalValue, compareFields)
|
||||
|
||||
@ -538,6 +538,67 @@ function generateUiSchema(
|
||||
return uiSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes hidden field properties from the JSON schema itself so RJSF won't
|
||||
* validate them. The existing ui:widget=hidden approach only hides rendering
|
||||
* but still validates — fields with server-only values (e.g. raw_coordinates
|
||||
* serialized as null) cause spurious validation errors.
|
||||
*
|
||||
* Supports dotted paths ("mask"), nested paths ("genai.enabled_in_config"),
|
||||
* and wildcard segments ("filters.*.mask") where `*` matches
|
||||
* additionalProperties.
|
||||
*/
|
||||
function stripHiddenFieldsFromSchema(
|
||||
schema: RJSFSchema,
|
||||
hiddenFields: string[],
|
||||
): void {
|
||||
for (const pattern of hiddenFields) {
|
||||
if (!pattern) continue;
|
||||
const segments = pattern.split(".");
|
||||
removePropertyBySegments(schema, segments);
|
||||
}
|
||||
}
|
||||
|
||||
function removePropertyBySegments(
|
||||
schema: RJSFSchema,
|
||||
segments: string[],
|
||||
): void {
|
||||
if (segments.length === 0 || !isSchemaObject(schema)) return;
|
||||
|
||||
const [head, ...rest] = segments;
|
||||
const props = schema.properties as Record<string, RJSFSchema> | undefined;
|
||||
|
||||
if (rest.length === 0) {
|
||||
// Terminal segment — delete the property
|
||||
if (head === "*") {
|
||||
// Wildcard at leaf: strip from additionalProperties
|
||||
if (isSchemaObject(schema.additionalProperties)) {
|
||||
// Nothing to delete — "*" as the last segment means "every dynamic key".
|
||||
// The parent's additionalProperties schema IS the dynamic value, not a
|
||||
// container. In practice hidden-field patterns always have a named leaf
|
||||
// after the wildcard (e.g. "filters.*.mask"), so this branch is a no-op.
|
||||
}
|
||||
} else if (props && head in props) {
|
||||
delete props[head];
|
||||
if (Array.isArray(schema.required)) {
|
||||
schema.required = (schema.required as string[]).filter(
|
||||
(r) => r !== head,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (head === "*") {
|
||||
// Wildcard segment — descend into additionalProperties
|
||||
if (isSchemaObject(schema.additionalProperties)) {
|
||||
removePropertyBySegments(schema.additionalProperties as RJSFSchema, rest);
|
||||
}
|
||||
} else if (props && head in props && isSchemaObject(props[head])) {
|
||||
removePropertyBySegments(props[head], rest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a Pydantic JSON Schema to RJSF format
|
||||
* Resolves references and generates appropriate uiSchema
|
||||
@ -550,6 +611,11 @@ export function transformSchema(
|
||||
const cleanSchema = resolveAndCleanSchema(rawSchema);
|
||||
const normalizedSchema = normalizeNullableSchema(cleanSchema);
|
||||
|
||||
// Remove hidden fields from schema so RJSF won't validate them
|
||||
if (options.hiddenFields && options.hiddenFields.length > 0) {
|
||||
stripHiddenFieldsFromSchema(normalizedSchema, options.hiddenFields);
|
||||
}
|
||||
|
||||
// Generate uiSchema
|
||||
const uiSchema = generateUiSchema(normalizedSchema, options);
|
||||
|
||||
|
||||
@ -28,7 +28,11 @@ import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
import type {
|
||||
ConfigSectionData,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
} from "@/types/configForm";
|
||||
import useSWR from "swr";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||
@ -39,6 +43,7 @@ import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||
import UsersView from "@/views/settings/UsersView";
|
||||
import RolesView from "@/views/settings/RolesView";
|
||||
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||
import ProfilesView from "@/views/settings/ProfilesView";
|
||||
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
||||
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
|
||||
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
|
||||
@ -87,8 +92,13 @@ import { mutate } from "swr";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import {
|
||||
buildConfigDataForPath,
|
||||
parseProfileFromSectionPath,
|
||||
prepareSectionSavePayload,
|
||||
PROFILE_ELIGIBLE_SECTIONS,
|
||||
} from "@/utils/configUtil";
|
||||
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import SaveAllPreviewPopover, {
|
||||
@ -97,7 +107,8 @@ import SaveAllPreviewPopover, {
|
||||
import { useRestart } from "@/api/ws";
|
||||
|
||||
const allSettingsViews = [
|
||||
"profileSettings",
|
||||
"uiSettings",
|
||||
"profiles",
|
||||
"globalDetect",
|
||||
"globalRecording",
|
||||
"globalSnapshots",
|
||||
@ -178,15 +189,15 @@ const parsePendingDataKey = (pendingDataKey: string) => {
|
||||
};
|
||||
|
||||
const flattenOverrides = (
|
||||
value: unknown,
|
||||
value: JsonValue | undefined,
|
||||
path: string[] = [],
|
||||
): Array<{ path: string; value: unknown }> => {
|
||||
): Array<{ path: string; value: JsonValue }> => {
|
||||
if (value === undefined) return [];
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return [{ path: path.join("."), value }];
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) {
|
||||
return [{ path: path.join("."), value: {} }];
|
||||
}
|
||||
@ -308,7 +319,7 @@ const CameraTimestampStyleSettingsPage = createSectionPage(
|
||||
const settingsGroups = [
|
||||
{
|
||||
label: "general",
|
||||
items: [{ key: "profileSettings", component: UiSettingsView }],
|
||||
items: [{ key: "uiSettings", component: UiSettingsView }],
|
||||
},
|
||||
{
|
||||
label: "globalConfig",
|
||||
@ -334,6 +345,7 @@ const settingsGroups = [
|
||||
{
|
||||
label: "cameras",
|
||||
items: [
|
||||
{ key: "profiles", component: ProfilesView },
|
||||
{ key: "cameraManagement", component: CameraManagementView },
|
||||
{ key: "cameraDetect", component: CameraDetectSettingsPage },
|
||||
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
|
||||
@ -479,7 +491,7 @@ const CAMERA_SELECT_BUTTON_PAGES = [
|
||||
"regionGrid",
|
||||
];
|
||||
|
||||
const ALLOWED_VIEWS_FOR_VIEWER = ["profileSettings", "notifications"];
|
||||
const ALLOWED_VIEWS_FOR_VIEWER = ["uiSettings", "notifications"];
|
||||
|
||||
// keys for camera sections
|
||||
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||
@ -503,6 +515,28 @@ const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||
timestamp_style: "cameraTimestampStyle",
|
||||
};
|
||||
|
||||
// Reverse mapping: page key → config section key
|
||||
const REVERSE_CAMERA_SECTION_MAPPING: Record<string, string> =
|
||||
Object.fromEntries(
|
||||
Object.entries(CAMERA_SECTION_MAPPING).map(([section, page]) => [
|
||||
page,
|
||||
section,
|
||||
]),
|
||||
);
|
||||
// masksAndZones is a composite page, not in CAMERA_SECTION_MAPPING
|
||||
REVERSE_CAMERA_SECTION_MAPPING["masksAndZones"] = "masksAndZones";
|
||||
|
||||
// Pages where the profile dropdown should appear
|
||||
const PROFILE_DROPDOWN_PAGES = new Set(
|
||||
Object.entries(REVERSE_CAMERA_SECTION_MAPPING)
|
||||
.filter(
|
||||
([, sectionKey]) =>
|
||||
PROFILE_ELIGIBLE_SECTIONS.has(sectionKey) ||
|
||||
sectionKey === "masksAndZones",
|
||||
)
|
||||
.map(([pageKey]) => pageKey),
|
||||
);
|
||||
|
||||
// keys for global sections
|
||||
const GLOBAL_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||
detect: "globalDetect",
|
||||
@ -594,7 +628,7 @@ function MobileMenuItem({
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [page, setPage] = useState<SettingsType>("profileSettings");
|
||||
const [page, setPage] = useState<SettingsType>("uiSettings");
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||
const [contentMobileOpen, setContentMobileOpen] = useState(false);
|
||||
const [sectionStatusByKey, setSectionStatusByKey] = useState<
|
||||
@ -602,6 +636,7 @@ export default function Settings() {
|
||||
>({});
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@ -618,9 +653,28 @@ export default function Settings() {
|
||||
|
||||
// Store pending form data keyed by "sectionKey" or "cameraName::sectionKey"
|
||||
const [pendingDataBySection, setPendingDataBySection] = useState<
|
||||
Record<string, unknown>
|
||||
Record<string, ConfigSectionData>
|
||||
>({});
|
||||
|
||||
// Profile editing state
|
||||
const [editingProfile, setEditingProfile] = useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [profilesUIEnabled, setProfilesUIEnabled] = useState(false);
|
||||
|
||||
const allProfileNames = useMemo(() => {
|
||||
if (!config?.profiles) return [];
|
||||
return Object.keys(config.profiles).sort();
|
||||
}, [config]);
|
||||
|
||||
const profileFriendlyNames = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (profilesData?.profiles) {
|
||||
profilesData.profiles.forEach((p) => map.set(p.name, p.friendly_name));
|
||||
}
|
||||
return map;
|
||||
}, [profilesData]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cameras = useMemo(() => {
|
||||
@ -692,11 +746,22 @@ export default function Settings() {
|
||||
|
||||
const { scope, cameraName, sectionPath } =
|
||||
parsePendingDataKey(pendingDataKey);
|
||||
const { isProfile, profileName, actualSection } =
|
||||
parseProfileFromSectionPath(sectionPath);
|
||||
const flattened = flattenOverrides(payload.sanitizedOverrides);
|
||||
const displaySection = isProfile ? actualSection : sectionPath;
|
||||
|
||||
flattened.forEach(({ path, value }) => {
|
||||
const fieldPath = path ? `${sectionPath}.${path}` : sectionPath;
|
||||
items.push({ scope, cameraName, fieldPath, value });
|
||||
const fieldPath = path ? `${displaySection}.${path}` : displaySection;
|
||||
items.push({
|
||||
scope,
|
||||
cameraName,
|
||||
profileName: isProfile
|
||||
? (profileFriendlyNames.get(profileName!) ?? profileName)
|
||||
: undefined,
|
||||
fieldPath,
|
||||
value,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@ -710,7 +775,7 @@ export default function Settings() {
|
||||
if (cameraCompare !== 0) return cameraCompare;
|
||||
return left.fieldPath.localeCompare(right.fieldPath);
|
||||
});
|
||||
}, [config, fullSchema, pendingDataBySection]);
|
||||
}, [config, fullSchema, pendingDataBySection, profileFriendlyNames]);
|
||||
|
||||
// Map a pendingDataKey to SettingsType menu key for clearing section status
|
||||
const pendingKeyToMenuKey = useCallback(
|
||||
@ -726,15 +791,20 @@ export default function Settings() {
|
||||
level = "global";
|
||||
}
|
||||
|
||||
// For profile keys like "profiles.armed.detect", extract the actual section
|
||||
const { actualSection } = parseProfileFromSectionPath(sectionPath);
|
||||
|
||||
if (level === "camera") {
|
||||
return CAMERA_SECTION_MAPPING[sectionPath] as SettingsType | undefined;
|
||||
return CAMERA_SECTION_MAPPING[actualSection] as
|
||||
| SettingsType
|
||||
| undefined;
|
||||
}
|
||||
return (
|
||||
(GLOBAL_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ??
|
||||
(ENRICHMENTS_SECTION_MAPPING[sectionPath] as
|
||||
(GLOBAL_SECTION_MAPPING[actualSection] as SettingsType | undefined) ??
|
||||
(ENRICHMENTS_SECTION_MAPPING[actualSection] as
|
||||
| SettingsType
|
||||
| undefined) ??
|
||||
(SYSTEM_SECTION_MAPPING[sectionPath] as SettingsType | undefined)
|
||||
(SYSTEM_SECTION_MAPPING[actualSection] as SettingsType | undefined)
|
||||
);
|
||||
},
|
||||
[],
|
||||
@ -759,6 +829,7 @@ export default function Settings() {
|
||||
|
||||
for (const key of pendingKeys) {
|
||||
const pendingData = pendingDataBySection[key];
|
||||
|
||||
try {
|
||||
const payload = prepareSectionSavePayload({
|
||||
pendingDataKey: key,
|
||||
@ -884,6 +955,7 @@ export default function Settings() {
|
||||
|
||||
setPendingDataBySection({});
|
||||
setUnsavedChanges(false);
|
||||
setEditingProfile({});
|
||||
|
||||
setSectionStatusByKey((prev) => {
|
||||
const updated = { ...prev };
|
||||
@ -940,7 +1012,7 @@ export default function Settings() {
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType)
|
||||
) {
|
||||
setPageToggle("profileSettings");
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(page as SettingsType);
|
||||
}
|
||||
@ -970,6 +1042,210 @@ export default function Settings() {
|
||||
}
|
||||
}, [t, contentMobileOpen]);
|
||||
|
||||
// Profile state handlers
|
||||
const handleSelectProfile = useCallback(
|
||||
(camera: string, _section: string, profile: string | null) => {
|
||||
setEditingProfile((prev) => {
|
||||
if (profile === null) {
|
||||
const { [camera]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [camera]: profile };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteProfileSection = useCallback(
|
||||
async (camera: string, section: string, profile: string) => {
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[camera]: {
|
||||
profiles: {
|
||||
[profile]: {
|
||||
[section]: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await mutate("config");
|
||||
// Switch back to base config
|
||||
handleSelectProfile(camera, section, null);
|
||||
toast.success(
|
||||
t("profiles.deleteSectionSuccess", {
|
||||
ns: "views/settings",
|
||||
section: t(`configForm.sections.${section}`, {
|
||||
ns: "views/settings",
|
||||
defaultValue: section,
|
||||
}),
|
||||
profile: profileFriendlyNames.get(profile) ?? profile,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.title", { ns: "common" }));
|
||||
}
|
||||
},
|
||||
[handleSelectProfile, profileFriendlyNames, t],
|
||||
);
|
||||
|
||||
const profileState: ProfileState = useMemo(
|
||||
() => ({
|
||||
editingProfile,
|
||||
allProfileNames,
|
||||
profileFriendlyNames,
|
||||
onSelectProfile: handleSelectProfile,
|
||||
onDeleteProfileSection: handleDeleteProfileSection,
|
||||
}),
|
||||
[
|
||||
editingProfile,
|
||||
allProfileNames,
|
||||
profileFriendlyNames,
|
||||
handleSelectProfile,
|
||||
handleDeleteProfileSection,
|
||||
],
|
||||
);
|
||||
|
||||
// Header profile dropdown: derive section key from current page
|
||||
const currentSectionKey = useMemo(
|
||||
() => REVERSE_CAMERA_SECTION_MAPPING[pageToggle] ?? null,
|
||||
[pageToggle],
|
||||
);
|
||||
|
||||
const headerEditingProfile = useMemo(() => {
|
||||
if (!selectedCamera || !currentSectionKey) return null;
|
||||
return editingProfile[selectedCamera] ?? null;
|
||||
}, [selectedCamera, currentSectionKey, editingProfile]);
|
||||
|
||||
const showProfileDropdown =
|
||||
PROFILE_DROPDOWN_PAGES.has(pageToggle) &&
|
||||
!!selectedCamera &&
|
||||
(allProfileNames.length > 0 || profilesUIEnabled);
|
||||
|
||||
const headerHasProfileData = useCallback(
|
||||
(profileName: string): boolean => {
|
||||
if (!config || !selectedCamera || !currentSectionKey) return false;
|
||||
const profileData =
|
||||
config.cameras[selectedCamera]?.profiles?.[profileName];
|
||||
if (!profileData) return false;
|
||||
|
||||
if (currentSectionKey === "masksAndZones") {
|
||||
const hasZones =
|
||||
profileData.zones && Object.keys(profileData.zones).length > 0;
|
||||
const hasMotionMasks =
|
||||
profileData.motion?.mask &&
|
||||
Object.keys(profileData.motion.mask).length > 0;
|
||||
const hasObjectMasks =
|
||||
(profileData.objects?.mask &&
|
||||
Object.keys(profileData.objects.mask).length > 0) ||
|
||||
(profileData.objects?.filters &&
|
||||
Object.values(profileData.objects.filters).some(
|
||||
(f) => f.mask && Object.keys(f.mask).length > 0,
|
||||
));
|
||||
return !!(hasZones || hasMotionMasks || hasObjectMasks);
|
||||
}
|
||||
|
||||
return !!profileData[currentSectionKey as keyof typeof profileData];
|
||||
},
|
||||
[config, selectedCamera, currentSectionKey],
|
||||
);
|
||||
|
||||
const handleDeleteProfileForCurrentSection = useCallback(
|
||||
async (profileName: string) => {
|
||||
if (!selectedCamera || !currentSectionKey) return;
|
||||
|
||||
if (currentSectionKey === "masksAndZones") {
|
||||
try {
|
||||
const profileData =
|
||||
config?.cameras?.[selectedCamera]?.profiles?.[profileName];
|
||||
if (!profileData) return;
|
||||
|
||||
// Build a targeted delete payload that only removes mask-related
|
||||
// sub-keys, not the entire motion/objects sections
|
||||
const deletePayload: JsonObject = {};
|
||||
|
||||
if (profileData.zones !== undefined) {
|
||||
deletePayload.zones = "";
|
||||
}
|
||||
|
||||
if (profileData.motion?.mask !== undefined) {
|
||||
deletePayload.motion = { mask: "" };
|
||||
}
|
||||
|
||||
if (profileData.objects) {
|
||||
const objDelete: JsonObject = {};
|
||||
if (profileData.objects.mask !== undefined) {
|
||||
objDelete.mask = "";
|
||||
}
|
||||
if (profileData.objects.filters) {
|
||||
const filtersDelete: JsonObject = {};
|
||||
for (const [filterName, filterVal] of Object.entries(
|
||||
profileData.objects.filters,
|
||||
)) {
|
||||
if (filterVal.mask !== undefined) {
|
||||
filtersDelete[filterName] = { mask: "" };
|
||||
}
|
||||
}
|
||||
if (Object.keys(filtersDelete).length > 0) {
|
||||
objDelete.filters = filtersDelete;
|
||||
}
|
||||
}
|
||||
if (Object.keys(objDelete).length > 0) {
|
||||
deletePayload.objects = objDelete;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(deletePayload).length === 0) return;
|
||||
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[selectedCamera]: {
|
||||
profiles: {
|
||||
[profileName]: deletePayload,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await mutate("config");
|
||||
handleSelectProfile(selectedCamera, "masksAndZones", null);
|
||||
toast.success(
|
||||
t("profiles.deleteSectionSuccess", {
|
||||
ns: "views/settings",
|
||||
section: t("configForm.sections.masksAndZones", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
profile: profileFriendlyNames.get(profileName) ?? profileName,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.title", { ns: "common" }));
|
||||
}
|
||||
} else {
|
||||
await handleDeleteProfileSection(
|
||||
selectedCamera,
|
||||
currentSectionKey,
|
||||
profileName,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCamera,
|
||||
currentSectionKey,
|
||||
config,
|
||||
handleSelectProfile,
|
||||
handleDeleteProfileSection,
|
||||
profileFriendlyNames,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSectionStatusChange = useCallback(
|
||||
(sectionKey: string, level: "global" | "camera", status: SectionStatus) => {
|
||||
// Map section keys to menu keys based on level
|
||||
@ -1016,24 +1292,64 @@ export default function Settings() {
|
||||
[],
|
||||
);
|
||||
|
||||
// The active profile being edited for the selected camera
|
||||
const activeEditingProfile = selectedCamera
|
||||
? (editingProfile[selectedCamera] ?? null)
|
||||
: null;
|
||||
|
||||
// Profile color for the active editing profile
|
||||
const activeProfileColor = useMemo(
|
||||
() =>
|
||||
activeEditingProfile
|
||||
? getProfileColor(activeEditingProfile, allProfileNames)
|
||||
: undefined,
|
||||
[activeEditingProfile, allProfileNames],
|
||||
);
|
||||
|
||||
// Initialize override status for all camera sections
|
||||
useEffect(() => {
|
||||
if (!selectedCamera || !cameraOverrides) return;
|
||||
|
||||
const overrideMap: Partial<
|
||||
Record<SettingsType, Pick<SectionStatus, "hasChanges" | "isOverridden">>
|
||||
Record<
|
||||
SettingsType,
|
||||
Pick<SectionStatus, "hasChanges" | "isOverridden" | "overrideSource">
|
||||
>
|
||||
> = {};
|
||||
|
||||
// Build a set of menu keys that have pending changes for this camera
|
||||
const pendingMenuKeys = new Set<string>();
|
||||
const cameraPrefix = `${selectedCamera}::`;
|
||||
for (const key of Object.keys(pendingDataBySection)) {
|
||||
if (key.startsWith(cameraPrefix)) {
|
||||
const menuKey = pendingKeyToMenuKey(key);
|
||||
if (menuKey) pendingMenuKeys.add(menuKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Get profile data if a profile is being edited
|
||||
const profileData = activeEditingProfile
|
||||
? config?.cameras?.[selectedCamera]?.profiles?.[activeEditingProfile]
|
||||
: undefined;
|
||||
|
||||
// Set override status for all camera sections using the shared mapping
|
||||
Object.entries(CAMERA_SECTION_MAPPING).forEach(
|
||||
([sectionKey, settingsKey]) => {
|
||||
const isOverridden = cameraOverrides.includes(sectionKey);
|
||||
// Check if there are pending changes for this camera and section
|
||||
const pendingDataKey = `${selectedCamera}::${sectionKey}`;
|
||||
const hasChanges = pendingDataKey in pendingDataBySection;
|
||||
const globalOverridden = cameraOverrides.includes(sectionKey);
|
||||
|
||||
// Check if the active profile overrides this section
|
||||
const profileOverrides = profileData
|
||||
? !!profileData[sectionKey as keyof typeof profileData]
|
||||
: false;
|
||||
|
||||
overrideMap[settingsKey] = {
|
||||
hasChanges,
|
||||
isOverridden,
|
||||
hasChanges: pendingMenuKeys.has(settingsKey),
|
||||
isOverridden: profileOverrides || globalOverridden,
|
||||
overrideSource: profileOverrides
|
||||
? "profile"
|
||||
: globalOverridden
|
||||
? "global"
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
@ -1046,12 +1362,20 @@ export default function Settings() {
|
||||
merged[key as SettingsType] = {
|
||||
hasChanges: status.hasChanges,
|
||||
isOverridden: status.isOverridden,
|
||||
overrideSource: status.overrideSource,
|
||||
hasValidationErrors: existingStatus?.hasValidationErrors ?? false,
|
||||
};
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
}, [selectedCamera, cameraOverrides, pendingDataBySection]);
|
||||
}, [
|
||||
selectedCamera,
|
||||
cameraOverrides,
|
||||
pendingDataBySection,
|
||||
pendingKeyToMenuKey,
|
||||
activeEditingProfile,
|
||||
config,
|
||||
]);
|
||||
|
||||
const renderMenuItemLabel = useCallback(
|
||||
(key: SettingsType) => {
|
||||
@ -1060,13 +1384,20 @@ export default function Settings() {
|
||||
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
||||
const showUnsavedDot = status?.hasChanges;
|
||||
|
||||
const dotColor =
|
||||
status?.overrideSource === "profile" && activeProfileColor
|
||||
? activeProfileColor.dot
|
||||
: "bg-selected";
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between pr-4 md:pr-0">
|
||||
<div>{t("menu." + key)}</div>
|
||||
{(showOverrideDot || showUnsavedDot) && (
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
{showOverrideDot && (
|
||||
<span className="inline-block size-2 rounded-full bg-selected" />
|
||||
<span
|
||||
className={cn("inline-block size-2 rounded-full", dotColor)}
|
||||
/>
|
||||
)}
|
||||
{showUnsavedDot && (
|
||||
<span className="inline-block size-2 rounded-full bg-danger" />
|
||||
@ -1076,7 +1407,7 @@ export default function Settings() {
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[sectionStatusByKey, t],
|
||||
[sectionStatusByKey, t, activeProfileColor],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
@ -1101,7 +1432,7 @@ export default function Settings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row text-center">
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="ml-2 text-lg">
|
||||
{t("menu.settings", { ns: "common" })}
|
||||
</h2>
|
||||
@ -1134,7 +1465,7 @@ export default function Settings() {
|
||||
key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("profileSettings");
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(key as SettingsType);
|
||||
}
|
||||
@ -1217,6 +1548,22 @@ export default function Settings() {
|
||||
updateZoneMaskFilter={setFilterZoneMask}
|
||||
/>
|
||||
)}
|
||||
{showProfileDropdown && currentSectionKey && (
|
||||
<ProfileSectionDropdown
|
||||
allProfileNames={allProfileNames}
|
||||
profileFriendlyNames={profileFriendlyNames}
|
||||
editingProfile={headerEditingProfile}
|
||||
hasProfileData={headerHasProfileData}
|
||||
onSelectProfile={(profile) =>
|
||||
handleSelectProfile(
|
||||
selectedCamera,
|
||||
currentSectionKey,
|
||||
profile,
|
||||
)
|
||||
}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
<CameraSelectButton
|
||||
allCameras={cameras}
|
||||
selectedCamera={selectedCamera}
|
||||
@ -1244,6 +1591,12 @@ export default function Settings() {
|
||||
onSectionStatusChange={handleSectionStatusChange}
|
||||
pendingDataBySection={pendingDataBySection}
|
||||
onPendingDataChange={handlePendingDataChange}
|
||||
profileState={profileState}
|
||||
onDeleteProfileSection={
|
||||
handleDeleteProfileForCurrentSection
|
||||
}
|
||||
profilesUIEnabled={profilesUIEnabled}
|
||||
setProfilesUIEnabled={setProfilesUIEnabled}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
@ -1288,10 +1641,12 @@ export default function Settings() {
|
||||
<div className="flex h-full flex-col">
|
||||
<Toaster position="top-center" />
|
||||
<div className="flex min-h-16 items-center justify-between border-b border-secondary p-3">
|
||||
<Heading as="h3" className="mb-0">
|
||||
{t("menu.settings", { ns: "common" })}
|
||||
</Heading>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="mr-2 flex w-full items-center justify-between gap-3">
|
||||
<Heading as="h3" className="mb-0">
|
||||
{t("menu.settings", { ns: "common" })}
|
||||
</Heading>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPendingChanges && (
|
||||
<div
|
||||
className={cn(
|
||||
@ -1327,7 +1682,7 @@ export default function Settings() {
|
||||
>
|
||||
{isSavingAll ? (
|
||||
<>
|
||||
<ActivityIndicator className="mr-2" />
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
{t("button.savingAll", { ns: "common" })}
|
||||
</>
|
||||
) : (
|
||||
@ -1344,6 +1699,21 @@ export default function Settings() {
|
||||
updateZoneMaskFilter={setFilterZoneMask}
|
||||
/>
|
||||
)}
|
||||
{showProfileDropdown && currentSectionKey && (
|
||||
<ProfileSectionDropdown
|
||||
allProfileNames={allProfileNames}
|
||||
profileFriendlyNames={profileFriendlyNames}
|
||||
editingProfile={headerEditingProfile}
|
||||
hasProfileData={headerHasProfileData}
|
||||
onSelectProfile={(profile) =>
|
||||
handleSelectProfile(
|
||||
selectedCamera,
|
||||
currentSectionKey,
|
||||
profile,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<CameraSelectButton
|
||||
allCameras={cameras}
|
||||
selectedCamera={selectedCamera}
|
||||
@ -1379,7 +1749,7 @@ export default function Settings() {
|
||||
filteredItems[0].key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("profileSettings");
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(
|
||||
filteredItems[0].key as SettingsType,
|
||||
@ -1419,7 +1789,7 @@ export default function Settings() {
|
||||
item.key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("profileSettings");
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(item.key as SettingsType);
|
||||
}
|
||||
@ -1459,6 +1829,10 @@ export default function Settings() {
|
||||
onSectionStatusChange={handleSectionStatusChange}
|
||||
pendingDataBySection={pendingDataBySection}
|
||||
onPendingDataChange={handlePendingDataChange}
|
||||
profileState={profileState}
|
||||
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
|
||||
profilesUIEnabled={profilesUIEnabled}
|
||||
setProfilesUIEnabled={setProfilesUIEnabled}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -14,6 +14,7 @@ export type Polygon = {
|
||||
friendly_name?: string;
|
||||
enabled?: boolean;
|
||||
enabled_in_config?: boolean;
|
||||
polygonSource?: "base" | "profile" | "override";
|
||||
};
|
||||
|
||||
export type ZoneFormValuesType = {
|
||||
|
||||
@ -23,7 +23,7 @@ export type ConfigFormContext = {
|
||||
extraHasChanges?: boolean;
|
||||
setExtraHasChanges?: (hasChanges: boolean) => void;
|
||||
formData?: JsonObject;
|
||||
pendingDataBySection?: Record<string, unknown>;
|
||||
pendingDataBySection?: Record<string, ConfigSectionData>;
|
||||
onPendingDataChange?: (
|
||||
sectionKey: string,
|
||||
cameraName: string | undefined,
|
||||
|
||||
@ -72,6 +72,10 @@ export interface CameraConfig {
|
||||
};
|
||||
enabled: boolean;
|
||||
enabled_in_config: boolean;
|
||||
face_recognition: {
|
||||
enabled: boolean;
|
||||
min_area: number;
|
||||
};
|
||||
ffmpeg: {
|
||||
global_args: string[];
|
||||
hwaccel_args: string;
|
||||
@ -99,6 +103,12 @@ export interface CameraConfig {
|
||||
quality: number;
|
||||
streams: { [key: string]: string };
|
||||
};
|
||||
lpr: {
|
||||
enabled: boolean;
|
||||
expire_time: number;
|
||||
min_area: number;
|
||||
enhancement: number;
|
||||
};
|
||||
motion: {
|
||||
contour_area: number;
|
||||
delta_alpha: number;
|
||||
@ -305,8 +315,31 @@ export interface CameraConfig {
|
||||
friendly_name?: string;
|
||||
};
|
||||
};
|
||||
profiles?: Record<string, CameraProfileConfig>;
|
||||
/** Pre-profile base section configs, present only when a profile is active */
|
||||
base_config?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type CameraProfileConfig = {
|
||||
enabled?: boolean;
|
||||
audio?: Partial<CameraConfig["audio"]>;
|
||||
birdseye?: Partial<CameraConfig["birdseye"]>;
|
||||
detect?: Partial<CameraConfig["detect"]>;
|
||||
face_recognition?: Partial<CameraConfig["face_recognition"]>;
|
||||
lpr?: Partial<CameraConfig["lpr"]>;
|
||||
motion?: Partial<CameraConfig["motion"]>;
|
||||
notifications?: Partial<CameraConfig["notifications"]>;
|
||||
objects?: Partial<CameraConfig["objects"]>;
|
||||
record?: Partial<CameraConfig["record"]>;
|
||||
review?: Partial<CameraConfig["review"]>;
|
||||
snapshots?: Partial<CameraConfig["snapshots"]>;
|
||||
zones?: Partial<CameraConfig["zones"]>;
|
||||
};
|
||||
|
||||
export type ProfileDefinitionConfig = {
|
||||
friendly_name: string;
|
||||
};
|
||||
|
||||
export type CameraGroupConfig = {
|
||||
cameras: string[];
|
||||
icon: IconName;
|
||||
@ -461,6 +494,8 @@ export interface FrigateConfig {
|
||||
|
||||
camera_groups: { [groupName: string]: CameraGroupConfig };
|
||||
|
||||
profiles: { [profileName: string]: ProfileDefinitionConfig };
|
||||
|
||||
lpr: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
33
web/src/types/profile.ts
Normal file
33
web/src/types/profile.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export type ProfileColor = {
|
||||
bg: string;
|
||||
text: string;
|
||||
dot: string;
|
||||
border: string;
|
||||
bgMuted: string;
|
||||
};
|
||||
|
||||
export type ProfileInfo = {
|
||||
name: string;
|
||||
friendly_name: string;
|
||||
};
|
||||
|
||||
export type ProfilesApiResponse = {
|
||||
profiles: ProfileInfo[];
|
||||
active_profile: string | null;
|
||||
};
|
||||
|
||||
export type ProfileState = {
|
||||
editingProfile: Record<string, string | null>;
|
||||
allProfileNames: string[];
|
||||
profileFriendlyNames: Map<string, string>;
|
||||
onSelectProfile: (
|
||||
camera: string,
|
||||
section: string,
|
||||
profile: string | null,
|
||||
) => void;
|
||||
onDeleteProfileSection: (
|
||||
camera: string,
|
||||
section: string,
|
||||
profile: string,
|
||||
) => void;
|
||||
};
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
import get from "lodash/get";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import merge from "lodash/merge";
|
||||
import unset from "lodash/unset";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import mergeWith from "lodash/mergeWith";
|
||||
@ -68,6 +69,63 @@ export const globalCameraDefaultSections = new Set([
|
||||
"ffmpeg",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the base (pre-profile) value for a camera section.
|
||||
*
|
||||
* When a profile is active the API populates `base_config` with original
|
||||
* section values. This helper returns that value when available, falling
|
||||
* back to the top-level (effective) value otherwise.
|
||||
*/
|
||||
export function getBaseCameraSectionValue(
|
||||
config: FrigateConfig | undefined,
|
||||
cameraName: string | undefined,
|
||||
sectionPath: string,
|
||||
): unknown {
|
||||
if (!config || !cameraName) return undefined;
|
||||
const cam = config.cameras?.[cameraName];
|
||||
if (!cam) return undefined;
|
||||
const base = cam.base_config?.[sectionPath];
|
||||
return base !== undefined ? base : get(cam, sectionPath);
|
||||
}
|
||||
|
||||
/** Sections that can appear inside a camera profile definition. */
|
||||
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
|
||||
"audio",
|
||||
"birdseye",
|
||||
"detect",
|
||||
"face_recognition",
|
||||
"lpr",
|
||||
"motion",
|
||||
"notifications",
|
||||
"objects",
|
||||
"record",
|
||||
"review",
|
||||
"snapshots",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse a section path that may encode a profile reference.
|
||||
*
|
||||
* Examples:
|
||||
* "detect" → { isProfile: false, actualSection: "detect" }
|
||||
* "profiles.armed.detect" → { isProfile: true, profileName: "armed", actualSection: "detect" }
|
||||
*/
|
||||
export function parseProfileFromSectionPath(sectionPath: string): {
|
||||
isProfile: boolean;
|
||||
profileName?: string;
|
||||
actualSection: string;
|
||||
} {
|
||||
const match = sectionPath.match(/^profiles\.([^.]+)\.(.+)$/);
|
||||
if (match) {
|
||||
return { isProfile: true, profileName: match[1], actualSection: match[2] };
|
||||
}
|
||||
return { isProfile: false, actualSection: sectionPath };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildOverrides — pure recursive diff of current vs stored config & defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -168,6 +226,26 @@ export function buildOverrides(
|
||||
// Normalize raw config data (strip internal fields) and remove any paths
|
||||
// listed in `hiddenFields` so they are not included in override computation.
|
||||
|
||||
// lodash `unset` treats `*` as a literal key. This helper expands wildcard
|
||||
// segments so that e.g. `"filters.*.mask"` unsets `filters.<each key>.mask`.
|
||||
function unsetWithWildcard(obj: Record<string, unknown>, path: string): void {
|
||||
if (!path.includes("*")) {
|
||||
unset(obj, path);
|
||||
return;
|
||||
}
|
||||
const segments = path.split(".");
|
||||
const starIndex = segments.indexOf("*");
|
||||
const prefix = segments.slice(0, starIndex).join(".");
|
||||
const suffix = segments.slice(starIndex + 1).join(".");
|
||||
const parent = prefix ? get(obj, prefix) : obj;
|
||||
if (parent && typeof parent === "object") {
|
||||
for (const key of Object.keys(parent as Record<string, unknown>)) {
|
||||
const fullPath = suffix ? `${key}.${suffix}` : key;
|
||||
unsetWithWildcard(parent as Record<string, unknown>, fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeSectionData(
|
||||
data: ConfigSectionData,
|
||||
hiddenFields?: string[],
|
||||
@ -179,7 +257,7 @@ export function sanitizeSectionData(
|
||||
const cleaned = cloneDeep(normalized) as ConfigSectionData;
|
||||
hiddenFields.forEach((path) => {
|
||||
if (!path) return;
|
||||
unset(cleaned, path);
|
||||
unsetWithWildcard(cleaned as Record<string, unknown>, path);
|
||||
});
|
||||
return cleaned;
|
||||
}
|
||||
@ -315,7 +393,7 @@ export function requiresRestartForFieldPath(
|
||||
|
||||
export interface SectionSavePayload {
|
||||
basePath: string;
|
||||
sanitizedOverrides: Record<string, unknown>;
|
||||
sanitizedOverrides: JsonObject;
|
||||
updateTopic: string | undefined;
|
||||
needsRestart: boolean;
|
||||
pendingDataKey: string;
|
||||
@ -421,23 +499,57 @@ export function prepareSectionSavePayload(opts: {
|
||||
level = "global";
|
||||
}
|
||||
|
||||
// Resolve section config
|
||||
const sectionConfig = getSectionConfig(sectionPath, level);
|
||||
// Detect profile-encoded section paths (e.g., "profiles.armed.detect")
|
||||
const profileInfo = parseProfileFromSectionPath(sectionPath);
|
||||
const schemaSection = profileInfo.actualSection;
|
||||
|
||||
// Resolve section schema
|
||||
const sectionSchema = extractSectionSchema(fullSchema, sectionPath, level);
|
||||
// Resolve section config using the actual section name (not the profile path)
|
||||
const sectionConfig = getSectionConfig(schemaSection, level);
|
||||
|
||||
// Resolve section schema using the actual section name
|
||||
const sectionSchema = extractSectionSchema(fullSchema, schemaSection, level);
|
||||
if (!sectionSchema) return null;
|
||||
|
||||
const modifiedSchema = modifySchemaForSection(
|
||||
sectionPath,
|
||||
schemaSection,
|
||||
level,
|
||||
sectionSchema,
|
||||
);
|
||||
|
||||
// Compute rawFormData (the current stored value for this section)
|
||||
// For profiles, merge base camera config with profile overrides (matching
|
||||
// what BaseSection displays in the form) so the diff only contains actual
|
||||
// user changes, not every field from the merged view.
|
||||
let rawSectionValue: unknown;
|
||||
if (level === "camera" && cameraName) {
|
||||
rawSectionValue = get(config.cameras?.[cameraName], sectionPath);
|
||||
if (profileInfo.isProfile) {
|
||||
const baseValue = getBaseCameraSectionValue(
|
||||
config,
|
||||
cameraName,
|
||||
profileInfo.actualSection,
|
||||
);
|
||||
const profileOverrides = get(config.cameras?.[cameraName], sectionPath);
|
||||
if (
|
||||
profileOverrides &&
|
||||
typeof profileOverrides === "object" &&
|
||||
baseValue &&
|
||||
typeof baseValue === "object"
|
||||
) {
|
||||
rawSectionValue = merge(
|
||||
cloneDeep(baseValue),
|
||||
cloneDeep(profileOverrides),
|
||||
);
|
||||
} else {
|
||||
rawSectionValue = baseValue;
|
||||
}
|
||||
} else {
|
||||
// Use base (pre-profile) value so the diff matches what the form shows
|
||||
rawSectionValue = getBaseCameraSectionValue(
|
||||
config,
|
||||
cameraName,
|
||||
sectionPath,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
rawSectionValue = get(config, sectionPath);
|
||||
}
|
||||
@ -446,10 +558,21 @@ export function prepareSectionSavePayload(opts: {
|
||||
? {}
|
||||
: rawSectionValue;
|
||||
|
||||
// For profile sections, also hide restart-required fields to match
|
||||
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
|
||||
// for fields that are hidden from the form during profile editing).
|
||||
let hiddenFieldsForSanitize = sectionConfig.hiddenFields;
|
||||
if (profileInfo.isProfile && sectionConfig.restartRequired?.length) {
|
||||
const base = sectionConfig.hiddenFields ?? [];
|
||||
hiddenFieldsForSanitize = [
|
||||
...new Set([...base, ...sectionConfig.restartRequired]),
|
||||
];
|
||||
}
|
||||
|
||||
// Sanitize raw form data
|
||||
const rawData = sanitizeSectionData(
|
||||
rawFormData as ConfigSectionData,
|
||||
sectionConfig.hiddenFields,
|
||||
hiddenFieldsForSanitize,
|
||||
);
|
||||
|
||||
// Compute schema defaults
|
||||
@ -457,7 +580,7 @@ export function prepareSectionSavePayload(opts: {
|
||||
? applySchemaDefaults(modifiedSchema, {})
|
||||
: {};
|
||||
const effectiveDefaults = getEffectiveDefaultsForSection(
|
||||
sectionPath,
|
||||
schemaSection,
|
||||
level,
|
||||
modifiedSchema ?? undefined,
|
||||
schemaDefaults,
|
||||
@ -466,7 +589,7 @@ export function prepareSectionSavePayload(opts: {
|
||||
// Build overrides
|
||||
const overrides = buildOverrides(pendingData, rawData, effectiveDefaults);
|
||||
const sanitizedOverrides = sanitizeOverridesForSection(
|
||||
sectionPath,
|
||||
schemaSection,
|
||||
level,
|
||||
overrides,
|
||||
);
|
||||
@ -474,7 +597,7 @@ export function prepareSectionSavePayload(opts: {
|
||||
if (
|
||||
!sanitizedOverrides ||
|
||||
typeof sanitizedOverrides !== "object" ||
|
||||
Object.keys(sanitizedOverrides as Record<string, unknown>).length === 0
|
||||
Object.keys(sanitizedOverrides as JsonObject).length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@ -485,9 +608,11 @@ export function prepareSectionSavePayload(opts: {
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
|
||||
// Compute updateTopic
|
||||
// Compute updateTopic — profile definitions don't trigger hot-reload
|
||||
let updateTopic: string | undefined;
|
||||
if (level === "camera" && cameraName) {
|
||||
if (profileInfo.isProfile) {
|
||||
updateTopic = undefined;
|
||||
} else if (level === "camera" && cameraName) {
|
||||
const topic = cameraUpdateTopicMap[sectionPath];
|
||||
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
|
||||
} else if (globalCameraDefaultSections.has(sectionPath)) {
|
||||
@ -497,16 +622,18 @@ export function prepareSectionSavePayload(opts: {
|
||||
updateTopic = `config/${sectionPath}`;
|
||||
}
|
||||
|
||||
// Restart detection
|
||||
const needsRestart = requiresRestartForOverrides(
|
||||
sanitizedOverrides,
|
||||
sectionConfig.restartRequired,
|
||||
true,
|
||||
);
|
||||
// Restart detection — profile definitions never need restart
|
||||
const needsRestart = profileInfo.isProfile
|
||||
? false
|
||||
: requiresRestartForOverrides(
|
||||
sanitizedOverrides,
|
||||
sectionConfig.restartRequired,
|
||||
true,
|
||||
);
|
||||
|
||||
return {
|
||||
basePath,
|
||||
sanitizedOverrides: sanitizedOverrides as Record<string, unknown>,
|
||||
sanitizedOverrides: sanitizedOverrides as JsonObject,
|
||||
updateTopic,
|
||||
needsRestart,
|
||||
pendingDataKey,
|
||||
|
||||
124
web/src/utils/profileColors.ts
Normal file
124
web/src/utils/profileColors.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import type { ProfileColor } from "@/types/profile";
|
||||
|
||||
const PROFILE_COLORS: ProfileColor[] = [
|
||||
{
|
||||
bg: "bg-pink-400",
|
||||
text: "text-pink-400",
|
||||
dot: "bg-pink-400",
|
||||
border: "border-pink-400",
|
||||
bgMuted: "bg-pink-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-violet-500",
|
||||
text: "text-violet-500",
|
||||
dot: "bg-violet-500",
|
||||
border: "border-violet-500",
|
||||
bgMuted: "bg-violet-500/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-lime-500",
|
||||
text: "text-lime-500",
|
||||
dot: "bg-lime-500",
|
||||
border: "border-lime-500",
|
||||
bgMuted: "bg-lime-500/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-teal-400",
|
||||
text: "text-teal-400",
|
||||
dot: "bg-teal-400",
|
||||
border: "border-teal-400",
|
||||
bgMuted: "bg-teal-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-sky-400",
|
||||
text: "text-sky-400",
|
||||
dot: "bg-sky-400",
|
||||
border: "border-sky-400",
|
||||
bgMuted: "bg-sky-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-emerald-400",
|
||||
text: "text-emerald-400",
|
||||
dot: "bg-emerald-400",
|
||||
border: "border-emerald-400",
|
||||
bgMuted: "bg-emerald-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-indigo-400",
|
||||
text: "text-indigo-400",
|
||||
dot: "bg-indigo-400",
|
||||
border: "border-indigo-400",
|
||||
bgMuted: "bg-indigo-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-rose-400",
|
||||
text: "text-rose-400",
|
||||
dot: "bg-rose-400",
|
||||
border: "border-rose-400",
|
||||
bgMuted: "bg-rose-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-cyan-300",
|
||||
text: "text-cyan-300",
|
||||
dot: "bg-cyan-300",
|
||||
border: "border-cyan-300",
|
||||
bgMuted: "bg-cyan-300/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-purple-400",
|
||||
text: "text-purple-400",
|
||||
dot: "bg-purple-400",
|
||||
border: "border-purple-400",
|
||||
bgMuted: "bg-purple-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-green-400",
|
||||
text: "text-green-400",
|
||||
dot: "bg-green-400",
|
||||
border: "border-green-400",
|
||||
bgMuted: "bg-green-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-amber-400",
|
||||
text: "text-amber-400",
|
||||
dot: "bg-amber-400",
|
||||
border: "border-amber-400",
|
||||
bgMuted: "bg-amber-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-slate-400",
|
||||
text: "text-slate-400",
|
||||
dot: "bg-slate-400",
|
||||
border: "border-slate-400",
|
||||
bgMuted: "bg-slate-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-orange-300",
|
||||
text: "text-orange-300",
|
||||
dot: "bg-orange-300",
|
||||
border: "border-orange-300",
|
||||
bgMuted: "bg-orange-300/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-blue-300",
|
||||
text: "text-blue-300",
|
||||
dot: "bg-blue-300",
|
||||
border: "border-blue-300",
|
||||
bgMuted: "bg-blue-300/20",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a deterministic color for a profile name.
|
||||
*
|
||||
* Colors are assigned based on sorted position among all profile names,
|
||||
* so the same profile always gets the same color regardless of context.
|
||||
*/
|
||||
export function getProfileColor(
|
||||
profileName: string,
|
||||
allProfileNames: string[],
|
||||
): ProfileColor {
|
||||
const sorted = [...allProfileNames].sort();
|
||||
const index = sorted.indexOf(profileName);
|
||||
return PROFILE_COLORS[(index >= 0 ? index : 0) % PROFILE_COLORS.length];
|
||||
}
|
||||
@ -26,13 +26,25 @@ import axios from "axios";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import type { ProfileState } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type CameraManagementViewProps = {
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
profileState?: ProfileState;
|
||||
};
|
||||
|
||||
export default function CameraManagementView({
|
||||
setUnsavedChanges,
|
||||
profileState,
|
||||
}: CameraManagementViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
@ -200,6 +212,17 @@ export default function CameraManagementView({
|
||||
)}
|
||||
</SettingsGroupCard>
|
||||
)}
|
||||
|
||||
{profileState &&
|
||||
profileState.allProfileNames.length > 0 &&
|
||||
enabledCameras.length > 0 && (
|
||||
<ProfileCameraEnableSection
|
||||
profileState={profileState}
|
||||
cameras={enabledCameras}
|
||||
config={config}
|
||||
onConfigChanged={updateConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@ -364,3 +387,192 @@ function CameraConfigEnableSwitch({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileCameraEnableSectionProps = {
|
||||
profileState: ProfileState;
|
||||
cameras: string[];
|
||||
config: FrigateConfig | undefined;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function ProfileCameraEnableSection({
|
||||
profileState,
|
||||
cameras,
|
||||
config,
|
||||
onConfigChanged,
|
||||
}: ProfileCameraEnableSectionProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const [selectedProfile, setSelectedProfile] = useState<string>(
|
||||
profileState.allProfileNames[0] ?? "",
|
||||
);
|
||||
const [savingCamera, setSavingCamera] = useState<string | null>(null);
|
||||
// Optimistic local state: the parsed config API doesn't reflect profile
|
||||
// enabled changes until Frigate restarts, so we track saved values locally.
|
||||
const [localOverrides, setLocalOverrides] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
|
||||
const handleEnabledChange = useCallback(
|
||||
async (camera: string, value: string) => {
|
||||
setSavingCamera(camera);
|
||||
try {
|
||||
const enabledValue =
|
||||
value === "enabled" ? true : value === "disabled" ? false : null;
|
||||
const configData =
|
||||
enabledValue === null
|
||||
? {
|
||||
cameras: {
|
||||
[camera]: {
|
||||
profiles: { [selectedProfile]: { enabled: "" } },
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
cameras: {
|
||||
[camera]: {
|
||||
profiles: { [selectedProfile]: { enabled: enabledValue } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: configData,
|
||||
});
|
||||
await onConfigChanged();
|
||||
|
||||
setLocalOverrides((prev) => ({
|
||||
...prev,
|
||||
[selectedProfile]: {
|
||||
...prev[selectedProfile],
|
||||
[camera]: value,
|
||||
},
|
||||
}));
|
||||
|
||||
toast.success(t("toast.save.success", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.title", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} finally {
|
||||
setSavingCamera(null);
|
||||
}
|
||||
},
|
||||
[selectedProfile, onConfigChanged, t],
|
||||
);
|
||||
|
||||
const getEnabledState = useCallback(
|
||||
(camera: string): string => {
|
||||
// Check optimistic local state first
|
||||
const localValue = localOverrides[selectedProfile]?.[camera];
|
||||
if (localValue) return localValue;
|
||||
|
||||
const profileData =
|
||||
config?.cameras?.[camera]?.profiles?.[selectedProfile];
|
||||
if (!profileData || profileData.enabled === undefined) return "inherit";
|
||||
return profileData.enabled ? "enabled" : "disabled";
|
||||
},
|
||||
[config, selectedProfile, localOverrides],
|
||||
);
|
||||
|
||||
if (!selectedProfile) return null;
|
||||
|
||||
return (
|
||||
<SettingsGroupCard
|
||||
title={t("cameraManagement.profiles.title", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
>
|
||||
<div className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
{t("cameraManagement.profiles.selectLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cameraManagement.profiles.description", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-4`}>
|
||||
<Select value={selectedProfile} onValueChange={setSelectedProfile}>
|
||||
<SelectTrigger className="w-full max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profileState.allProfileNames.map((profile) => {
|
||||
const color = getProfileColor(
|
||||
profile,
|
||||
profileState.allProfileNames,
|
||||
);
|
||||
return (
|
||||
<SelectItem key={profile} value={profile}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
color.dot,
|
||||
)}
|
||||
/>
|
||||
{profileState.profileFriendlyNames.get(profile) ??
|
||||
profile}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||
{cameras.map((camera) => {
|
||||
const state = getEnabledState(camera);
|
||||
const isSaving = savingCamera === camera;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={camera}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
<CameraNameLabel camera={camera} />
|
||||
{isSaving ? (
|
||||
<ActivityIndicator className="h-5 w-20" size={16} />
|
||||
) : (
|
||||
<Select
|
||||
value={state}
|
||||
onValueChange={(v) => handleEnabledChange(camera, v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[120px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">
|
||||
{t("cameraManagement.profiles.inherit", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="enabled">
|
||||
{t("cameraManagement.profiles.enabled", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="disabled">
|
||||
{t("cameraManagement.profiles.disabled", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroupCard>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -35,17 +35,19 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ProfileState } from "@/types/profile";
|
||||
type MasksAndZoneViewProps = {
|
||||
selectedCamera: string;
|
||||
selectedZoneMask?: PolygonType[];
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
profileState?: ProfileState;
|
||||
};
|
||||
|
||||
export default function MasksAndZonesView({
|
||||
selectedCamera,
|
||||
selectedZoneMask,
|
||||
setUnsavedChanges,
|
||||
profileState,
|
||||
}: MasksAndZoneViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
@ -70,6 +72,10 @@ export default function MasksAndZonesView({
|
||||
const [activeLine, setActiveLine] = useState<number | undefined>();
|
||||
const [snapPoints, setSnapPoints] = useState(false);
|
||||
|
||||
// Profile state
|
||||
const currentEditingProfile =
|
||||
profileState?.editingProfile[selectedCamera] ?? null;
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (config && selectedCamera) {
|
||||
return config.cameras[selectedCamera];
|
||||
@ -228,18 +234,94 @@ export default function MasksAndZonesView({
|
||||
[allPolygons, scaledHeight, scaledWidth, t],
|
||||
);
|
||||
|
||||
// Helper to dim colors for base polygons in profile mode
|
||||
const dimColor = useCallback(
|
||||
(color: number[]): number[] => {
|
||||
if (!currentEditingProfile) return color;
|
||||
return color.map((c) => Math.round(c * 0.4 + 153 * 0.6));
|
||||
},
|
||||
[currentEditingProfile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) {
|
||||
const zones = Object.entries(cameraConfig.zones).map(
|
||||
([name, zoneData], index) => ({
|
||||
const profileData = currentEditingProfile
|
||||
? cameraConfig.profiles?.[currentEditingProfile]
|
||||
: undefined;
|
||||
|
||||
// When a profile is active, the top-level sections contain
|
||||
// effective (profile-merged) values. Use base_config for the
|
||||
// original base values so the "Base Config" view is accurate and
|
||||
// the base layer for profile merging is correct.
|
||||
const baseMotion = (cameraConfig.base_config?.motion ??
|
||||
cameraConfig.motion) as typeof cameraConfig.motion;
|
||||
const baseObjects = (cameraConfig.base_config?.objects ??
|
||||
cameraConfig.objects) as typeof cameraConfig.objects;
|
||||
const baseZones = (cameraConfig.base_config?.zones ??
|
||||
cameraConfig.zones) as typeof cameraConfig.zones;
|
||||
|
||||
// Build base zone names set for source tracking
|
||||
const baseZoneNames = new Set(Object.keys(baseZones));
|
||||
const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {}));
|
||||
const baseMotionMaskNames = new Set(Object.keys(baseMotion.mask || {}));
|
||||
const profileMotionMaskNames = new Set(
|
||||
Object.keys(profileData?.motion?.mask ?? {}),
|
||||
);
|
||||
const baseGlobalObjectMaskNames = new Set(
|
||||
Object.keys(baseObjects.mask || {}),
|
||||
);
|
||||
const profileGlobalObjectMaskNames = new Set(
|
||||
Object.keys(profileData?.objects?.mask ?? {}),
|
||||
);
|
||||
|
||||
// Merge zones: profile zones override base zones with same name
|
||||
const mergedZones = new Map<
|
||||
string,
|
||||
{
|
||||
data: CameraConfig["zones"][string];
|
||||
source: "base" | "profile" | "override";
|
||||
}
|
||||
>();
|
||||
|
||||
for (const [name, zoneData] of Object.entries(baseZones)) {
|
||||
if (currentEditingProfile && profileZoneNames.has(name)) {
|
||||
// Profile overrides this base zone
|
||||
mergedZones.set(name, {
|
||||
data: profileData!.zones![name]!,
|
||||
source: "override",
|
||||
});
|
||||
} else {
|
||||
mergedZones.set(name, {
|
||||
data: zoneData,
|
||||
source: currentEditingProfile ? "base" : "base",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add profile-only zones
|
||||
if (profileData?.zones) {
|
||||
for (const [name, zoneData] of Object.entries(profileData.zones)) {
|
||||
if (!baseZoneNames.has(name)) {
|
||||
mergedZones.set(name, { data: zoneData!, source: "profile" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zoneIndex = 0;
|
||||
const zones: Polygon[] = [];
|
||||
for (const [name, { data: zoneData, source }] of mergedZones) {
|
||||
const isBase = source === "base" && !!currentEditingProfile;
|
||||
const baseColor = zoneData.color ??
|
||||
baseZones[name]?.color ?? [128, 128, 0];
|
||||
zones.push({
|
||||
type: "zone" as PolygonType,
|
||||
typeIndex: index,
|
||||
typeIndex: zoneIndex,
|
||||
camera: cameraConfig.name,
|
||||
name,
|
||||
friendly_name: zoneData.friendly_name,
|
||||
enabled: zoneData.enabled,
|
||||
enabled_in_config: zoneData.enabled_in_config,
|
||||
objects: zoneData.objects,
|
||||
objects: zoneData.objects ?? [],
|
||||
points: interpolatePoints(
|
||||
parseCoordinates(zoneData.coordinates),
|
||||
1,
|
||||
@ -248,21 +330,60 @@ export default function MasksAndZonesView({
|
||||
scaledHeight,
|
||||
),
|
||||
distances:
|
||||
zoneData.distances?.map((distance) => parseFloat(distance)) ?? [],
|
||||
zoneData.distances?.map((distance: string) =>
|
||||
parseFloat(distance),
|
||||
) ?? [],
|
||||
isFinished: true,
|
||||
color: zoneData.color,
|
||||
}),
|
||||
);
|
||||
color: isBase ? dimColor(baseColor) : baseColor,
|
||||
polygonSource: currentEditingProfile ? source : undefined,
|
||||
});
|
||||
zoneIndex++;
|
||||
}
|
||||
|
||||
let motionMasks: Polygon[] = [];
|
||||
let globalObjectMasks: Polygon[] = [];
|
||||
let objectMasks: Polygon[] = [];
|
||||
// Merge motion masks
|
||||
const mergedMotionMasks = new Map<
|
||||
string,
|
||||
{
|
||||
data: CameraConfig["motion"]["mask"][string];
|
||||
source: "base" | "profile" | "override";
|
||||
}
|
||||
>();
|
||||
|
||||
// Motion masks are a dict with mask_id as key
|
||||
motionMasks = Object.entries(cameraConfig.motion.mask || {}).map(
|
||||
([maskId, maskData], index) => ({
|
||||
for (const [maskId, maskData] of Object.entries(baseMotion.mask || {})) {
|
||||
if (currentEditingProfile && profileMotionMaskNames.has(maskId)) {
|
||||
mergedMotionMasks.set(maskId, {
|
||||
data: profileData!.motion!.mask![maskId],
|
||||
source: "override",
|
||||
});
|
||||
} else {
|
||||
mergedMotionMasks.set(maskId, {
|
||||
data: maskData,
|
||||
source: currentEditingProfile ? "base" : "base",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (profileData?.motion?.mask) {
|
||||
for (const [maskId, maskData] of Object.entries(
|
||||
profileData.motion.mask,
|
||||
)) {
|
||||
if (!baseMotionMaskNames.has(maskId)) {
|
||||
mergedMotionMasks.set(maskId, {
|
||||
data: maskData,
|
||||
source: "profile",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let motionMaskIndex = 0;
|
||||
const motionMasks: Polygon[] = [];
|
||||
for (const [maskId, { data: maskData, source }] of mergedMotionMasks) {
|
||||
const isBase = source === "base" && !!currentEditingProfile;
|
||||
const baseColor = [0, 0, 255];
|
||||
motionMasks.push({
|
||||
type: "motion_mask" as PolygonType,
|
||||
typeIndex: index,
|
||||
typeIndex: motionMaskIndex,
|
||||
camera: cameraConfig.name,
|
||||
name: maskId,
|
||||
friendly_name: maskData.friendly_name,
|
||||
@ -278,15 +399,59 @@ export default function MasksAndZonesView({
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [0, 0, 255],
|
||||
}),
|
||||
);
|
||||
color: isBase ? dimColor(baseColor) : baseColor,
|
||||
polygonSource: currentEditingProfile ? source : undefined,
|
||||
});
|
||||
motionMaskIndex++;
|
||||
}
|
||||
|
||||
// Global object masks are a dict with mask_id as key
|
||||
globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map(
|
||||
([maskId, maskData], index) => ({
|
||||
// Merge global object masks
|
||||
const mergedGlobalObjectMasks = new Map<
|
||||
string,
|
||||
{
|
||||
data: CameraConfig["objects"]["mask"][string];
|
||||
source: "base" | "profile" | "override";
|
||||
}
|
||||
>();
|
||||
|
||||
for (const [maskId, maskData] of Object.entries(baseObjects.mask || {})) {
|
||||
if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) {
|
||||
mergedGlobalObjectMasks.set(maskId, {
|
||||
data: profileData!.objects!.mask![maskId],
|
||||
source: "override",
|
||||
});
|
||||
} else {
|
||||
mergedGlobalObjectMasks.set(maskId, {
|
||||
data: maskData,
|
||||
source: currentEditingProfile ? "base" : "base",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (profileData?.objects?.mask) {
|
||||
for (const [maskId, maskData] of Object.entries(
|
||||
profileData.objects.mask,
|
||||
)) {
|
||||
if (!baseGlobalObjectMaskNames.has(maskId)) {
|
||||
mergedGlobalObjectMasks.set(maskId, {
|
||||
data: maskData,
|
||||
source: "profile",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let objectMaskIndex = 0;
|
||||
const globalObjectMasks: Polygon[] = [];
|
||||
for (const [
|
||||
maskId,
|
||||
{ data: maskData, source },
|
||||
] of mergedGlobalObjectMasks) {
|
||||
const isBase = source === "base" && !!currentEditingProfile;
|
||||
const baseColor = [128, 128, 128];
|
||||
globalObjectMasks.push({
|
||||
type: "object_mask" as PolygonType,
|
||||
typeIndex: index,
|
||||
typeIndex: objectMaskIndex,
|
||||
camera: cameraConfig.name,
|
||||
name: maskId,
|
||||
friendly_name: maskData.friendly_name,
|
||||
@ -302,13 +467,41 @@ export default function MasksAndZonesView({
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [128, 128, 128],
|
||||
}),
|
||||
);
|
||||
color: isBase ? dimColor(baseColor) : baseColor,
|
||||
polygonSource: currentEditingProfile ? source : undefined,
|
||||
});
|
||||
objectMaskIndex++;
|
||||
}
|
||||
|
||||
let objectMaskIndex = globalObjectMasks.length;
|
||||
objectMaskIndex = globalObjectMasks.length;
|
||||
|
||||
objectMasks = Object.entries(cameraConfig.objects.filters)
|
||||
// Build per-object filter mask names for profile tracking
|
||||
const baseFilterMaskNames = new Set<string>();
|
||||
for (const [, filterConfig] of Object.entries(
|
||||
baseObjects.filters || {},
|
||||
)) {
|
||||
for (const maskId of Object.keys(filterConfig.mask || {})) {
|
||||
if (!maskId.startsWith("global_")) {
|
||||
baseFilterMaskNames.add(maskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profileFilterMaskNames = new Set<string>();
|
||||
if (profileData?.objects?.filters) {
|
||||
for (const [, filterConfig] of Object.entries(
|
||||
profileData.objects.filters,
|
||||
)) {
|
||||
if (filterConfig?.mask) {
|
||||
for (const maskId of Object.keys(filterConfig.mask)) {
|
||||
profileFilterMaskNames.add(maskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-object filter masks (base)
|
||||
const objectMasks: Polygon[] = Object.entries(baseObjects.filters || {})
|
||||
.filter(
|
||||
([, filterConfig]) =>
|
||||
filterConfig.mask && Object.keys(filterConfig.mask).length > 0,
|
||||
@ -316,22 +509,36 @@ export default function MasksAndZonesView({
|
||||
.flatMap(([objectName, filterConfig]): Polygon[] => {
|
||||
return Object.entries(filterConfig.mask || {}).flatMap(
|
||||
([maskId, maskData]) => {
|
||||
// Skip if this mask is a global mask (prefixed with "global_")
|
||||
if (maskId.startsWith("global_")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newMask = {
|
||||
const source: "base" | "override" = currentEditingProfile
|
||||
? profileFilterMaskNames.has(maskId)
|
||||
? "override"
|
||||
: "base"
|
||||
: "base";
|
||||
const isBase = source === "base" && !!currentEditingProfile;
|
||||
|
||||
// If override, use profile data
|
||||
const finalData =
|
||||
source === "override" && profileData?.objects?.filters
|
||||
? (profileData.objects.filters[objectName]?.mask?.[maskId] ??
|
||||
maskData)
|
||||
: maskData;
|
||||
|
||||
const baseColor = [128, 128, 128];
|
||||
const newMask: Polygon = {
|
||||
type: "object_mask" as PolygonType,
|
||||
typeIndex: objectMaskIndex,
|
||||
camera: cameraConfig.name,
|
||||
name: maskId,
|
||||
friendly_name: maskData.friendly_name,
|
||||
enabled: maskData.enabled,
|
||||
enabled_in_config: maskData.enabled_in_config,
|
||||
friendly_name: finalData.friendly_name,
|
||||
enabled: finalData.enabled,
|
||||
enabled_in_config: finalData.enabled_in_config,
|
||||
objects: [objectName],
|
||||
points: interpolatePoints(
|
||||
parseCoordinates(maskData.coordinates),
|
||||
parseCoordinates(finalData.coordinates),
|
||||
1,
|
||||
1,
|
||||
scaledWidth,
|
||||
@ -339,7 +546,8 @@ export default function MasksAndZonesView({
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [128, 128, 128],
|
||||
color: isBase ? dimColor(baseColor) : baseColor,
|
||||
polygonSource: currentEditingProfile ? source : undefined,
|
||||
};
|
||||
objectMaskIndex++;
|
||||
return [newMask];
|
||||
@ -347,6 +555,45 @@ export default function MasksAndZonesView({
|
||||
);
|
||||
});
|
||||
|
||||
// Add profile-only per-object filter masks
|
||||
if (profileData?.objects?.filters) {
|
||||
for (const [objectName, filterConfig] of Object.entries(
|
||||
profileData.objects.filters,
|
||||
)) {
|
||||
if (filterConfig?.mask) {
|
||||
for (const [maskId, maskData] of Object.entries(
|
||||
filterConfig.mask,
|
||||
)) {
|
||||
if (!baseFilterMaskNames.has(maskId) && maskData) {
|
||||
const baseColor = [128, 128, 128];
|
||||
objectMasks.push({
|
||||
type: "object_mask" as PolygonType,
|
||||
typeIndex: objectMaskIndex,
|
||||
camera: cameraConfig.name,
|
||||
name: maskId,
|
||||
friendly_name: maskData.friendly_name,
|
||||
enabled: maskData.enabled,
|
||||
enabled_in_config: maskData.enabled_in_config,
|
||||
objects: [objectName],
|
||||
points: interpolatePoints(
|
||||
parseCoordinates(maskData.coordinates),
|
||||
1,
|
||||
1,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: baseColor,
|
||||
polygonSource: "profile",
|
||||
});
|
||||
objectMaskIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAllPolygons([
|
||||
...zones,
|
||||
...motionMasks,
|
||||
@ -386,7 +633,14 @@ export default function MasksAndZonesView({
|
||||
}
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cameraConfig, containerRef, scaledHeight, scaledWidth]);
|
||||
}, [
|
||||
cameraConfig,
|
||||
containerRef,
|
||||
scaledHeight,
|
||||
scaledWidth,
|
||||
currentEditingProfile,
|
||||
dimColor,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editPane === undefined) {
|
||||
@ -403,6 +657,15 @@ export default function MasksAndZonesView({
|
||||
}
|
||||
}, [selectedCamera]);
|
||||
|
||||
// Cancel editing when profile selection changes
|
||||
useEffect(() => {
|
||||
if (editPaneRef.current !== undefined) {
|
||||
handleCancel();
|
||||
}
|
||||
// we only want to react to profile changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentEditingProfile]);
|
||||
|
||||
useSearchEffect("object_mask", (coordinates: string) => {
|
||||
if (!scaledWidth || !scaledHeight || isLoading) {
|
||||
return false;
|
||||
@ -473,6 +736,7 @@ export default function MasksAndZonesView({
|
||||
setActiveLine={setActiveLine}
|
||||
snapPoints={snapPoints}
|
||||
setSnapPoints={setSnapPoints}
|
||||
editingProfile={currentEditingProfile}
|
||||
/>
|
||||
)}
|
||||
{editPane == "motion_mask" && (
|
||||
@ -488,6 +752,7 @@ export default function MasksAndZonesView({
|
||||
onSave={handleSave}
|
||||
snapPoints={snapPoints}
|
||||
setSnapPoints={setSnapPoints}
|
||||
editingProfile={currentEditingProfile}
|
||||
/>
|
||||
)}
|
||||
{editPane == "object_mask" && (
|
||||
@ -503,13 +768,14 @@ export default function MasksAndZonesView({
|
||||
onSave={handleSave}
|
||||
snapPoints={snapPoints}
|
||||
setSnapPoints={setSnapPoints}
|
||||
editingProfile={currentEditingProfile}
|
||||
/>
|
||||
)}
|
||||
{editPane === undefined && (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("menu.masksAndZones")}
|
||||
</Heading>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Heading as="h4">{t("menu.masksAndZones")}</Heading>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
{(selectedZoneMask === undefined ||
|
||||
selectedZoneMask.includes("zone" as PolygonType)) && (
|
||||
@ -575,6 +841,8 @@ export default function MasksAndZonesView({
|
||||
setIsLoading={setIsLoading}
|
||||
loadingPolygonIndex={loadingPolygonIndex}
|
||||
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
||||
editingProfile={currentEditingProfile}
|
||||
allProfileNames={profileState?.allProfileNames}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -649,6 +917,8 @@ export default function MasksAndZonesView({
|
||||
setIsLoading={setIsLoading}
|
||||
loadingPolygonIndex={loadingPolygonIndex}
|
||||
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
||||
editingProfile={currentEditingProfile}
|
||||
allProfileNames={profileState?.allProfileNames}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -723,6 +993,8 @@ export default function MasksAndZonesView({
|
||||
setIsLoading={setIsLoading}
|
||||
loadingPolygonIndex={loadingPolygonIndex}
|
||||
setLoadingPolygonIndex={setLoadingPolygonIndex}
|
||||
editingProfile={currentEditingProfile}
|
||||
allProfileNames={profileState?.allProfileNames}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
733
web/src/views/settings/ProfilesView.tsx
Normal file
733
web/src/views/settings/ProfilesView.tsx
Normal file
@ -0,0 +1,733 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { JsonObject } from "@/types/configForm";
|
||||
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import NameAndIdFields from "@/components/input/NameAndIdFields";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
|
||||
type ProfilesViewProps = {
|
||||
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
profileState?: ProfileState;
|
||||
profilesUIEnabled?: boolean;
|
||||
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function ProfilesView({
|
||||
profileState,
|
||||
profilesUIEnabled,
|
||||
setProfilesUIEnabled,
|
||||
}: ProfilesViewProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const { data: profilesData, mutate: updateProfiles } =
|
||||
useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
const [activating, setActivating] = useState(false);
|
||||
const [deleteProfile, setDeleteProfile] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [renameProfile, setRenameProfile] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [expandedProfiles, setExpandedProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
|
||||
const allProfileNames = useMemo(
|
||||
() => profileState?.allProfileNames ?? [],
|
||||
[profileState?.allProfileNames],
|
||||
);
|
||||
|
||||
const addProfileSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: t("profiles.error.mustBeAtLeastTwoCharacters", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
})
|
||||
.refine((value) => !value.includes("."), {
|
||||
message: t("profiles.error.mustNotContainPeriod", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
})
|
||||
.refine((value) => !allProfileNames.includes(value), {
|
||||
message: t("profiles.error.alreadyExists", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
}),
|
||||
friendly_name: z.string().min(2, {
|
||||
message: t("profiles.error.mustBeAtLeastTwoCharacters", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
[t, allProfileNames],
|
||||
);
|
||||
|
||||
type AddProfileForm = z.infer<typeof addProfileSchema>;
|
||||
const addForm = useForm<AddProfileForm>({
|
||||
resolver: zodResolver(addProfileSchema),
|
||||
defaultValues: { friendly_name: "", name: "" },
|
||||
});
|
||||
|
||||
const profileFriendlyNames = profileState?.profileFriendlyNames;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.profiles", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const activeProfile = profilesData?.active_profile ?? null;
|
||||
|
||||
// Build overview data: for each profile, which cameras have which sections
|
||||
const profileOverviewData = useMemo(() => {
|
||||
if (!config || allProfileNames.length === 0) return {};
|
||||
|
||||
const data: Record<string, Record<string, string[]>> = {};
|
||||
const cameras = Object.keys(config.cameras).sort();
|
||||
|
||||
for (const profile of allProfileNames) {
|
||||
data[profile] = {};
|
||||
for (const camera of cameras) {
|
||||
const profileData = config.cameras[camera]?.profiles?.[profile];
|
||||
if (!profileData) continue;
|
||||
|
||||
const sections: string[] = [];
|
||||
for (const section of PROFILE_ELIGIBLE_SECTIONS) {
|
||||
if (
|
||||
profileData[section as keyof typeof profileData] !== undefined &&
|
||||
profileData[section as keyof typeof profileData] !== null
|
||||
) {
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
if (profileData.enabled !== undefined && profileData.enabled !== null) {
|
||||
sections.push("enabled");
|
||||
}
|
||||
if (sections.length > 0) {
|
||||
data[profile][camera] = sections;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}, [config, allProfileNames]);
|
||||
|
||||
const [addingProfile, setAddingProfile] = useState(false);
|
||||
|
||||
const handleAddSubmit = useCallback(
|
||||
async (data: AddProfileForm) => {
|
||||
const id = data.name.trim();
|
||||
const friendlyName = data.friendly_name.trim();
|
||||
if (!id || !friendlyName) return;
|
||||
|
||||
setAddingProfile(true);
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
profiles: { [id]: { friendly_name: friendlyName } },
|
||||
},
|
||||
});
|
||||
await updateConfig();
|
||||
await updateProfiles();
|
||||
toast.success(
|
||||
t("profiles.createSuccess", {
|
||||
ns: "views/settings",
|
||||
profile: friendlyName,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
setAddDialogOpen(false);
|
||||
addForm.reset();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} finally {
|
||||
setAddingProfile(false);
|
||||
}
|
||||
},
|
||||
[updateConfig, updateProfiles, addForm, t],
|
||||
);
|
||||
|
||||
const handleActivateProfile = useCallback(
|
||||
async (profile: string | null) => {
|
||||
setActivating(true);
|
||||
try {
|
||||
await axios.put("profile/set", {
|
||||
profile: profile || null,
|
||||
});
|
||||
await updateProfiles();
|
||||
toast.success(
|
||||
profile
|
||||
? t("profiles.activated", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyNames?.get(profile) ?? profile,
|
||||
})
|
||||
: t("profiles.deactivated", { ns: "views/settings" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} catch (err) {
|
||||
const message =
|
||||
axios.isAxiosError(err) && err.response?.data?.message
|
||||
? String(err.response.data.message)
|
||||
: undefined;
|
||||
toast.error(
|
||||
message || t("profiles.activateFailed", { ns: "views/settings" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} finally {
|
||||
setActivating(false);
|
||||
}
|
||||
},
|
||||
[updateProfiles, profileFriendlyNames, t],
|
||||
);
|
||||
|
||||
const handleDeleteProfile = useCallback(async () => {
|
||||
if (!deleteProfile || !config) return;
|
||||
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
// If this profile is active, deactivate it first
|
||||
if (activeProfile === deleteProfile) {
|
||||
await axios.put("profile/set", { profile: null });
|
||||
}
|
||||
|
||||
// Remove the profile from all cameras and the top-level definition
|
||||
const cameraData: JsonObject = {};
|
||||
for (const camera of Object.keys(config.cameras)) {
|
||||
if (config.cameras[camera]?.profiles?.[deleteProfile]) {
|
||||
cameraData[camera] = {
|
||||
profiles: { [deleteProfile]: "" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const configData: JsonObject = {
|
||||
profiles: { [deleteProfile]: "" },
|
||||
};
|
||||
if (Object.keys(cameraData).length > 0) {
|
||||
configData.cameras = cameraData;
|
||||
}
|
||||
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: configData,
|
||||
});
|
||||
|
||||
await updateConfig();
|
||||
await updateProfiles();
|
||||
|
||||
toast.success(
|
||||
t("profiles.deleteSuccess", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyNames?.get(deleteProfile) ?? deleteProfile,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
axios.isAxiosError(err) && err.response?.data?.message
|
||||
? String(err.response.data.message)
|
||||
: undefined;
|
||||
toast.error(
|
||||
errorMessage || t("toast.save.error.noMessage", { ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteProfile(null);
|
||||
}
|
||||
}, [
|
||||
deleteProfile,
|
||||
activeProfile,
|
||||
config,
|
||||
profileFriendlyNames,
|
||||
updateConfig,
|
||||
updateProfiles,
|
||||
t,
|
||||
]);
|
||||
|
||||
const toggleExpanded = useCallback((profile: string) => {
|
||||
setExpandedProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(profile)) {
|
||||
next.delete(profile);
|
||||
} else {
|
||||
next.add(profile);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
if (!renameProfile || !renameValue.trim()) return;
|
||||
|
||||
setRenaming(true);
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
profiles: {
|
||||
[renameProfile]: { friendly_name: renameValue.trim() },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateConfig();
|
||||
await updateProfiles();
|
||||
|
||||
toast.success(
|
||||
t("profiles.renameSuccess", {
|
||||
ns: "views/settings",
|
||||
profile: renameValue.trim(),
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
setRenameProfile(null);
|
||||
}
|
||||
}, [renameProfile, renameValue, updateConfig, updateProfiles, t]);
|
||||
|
||||
if (!config || !profilesData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasProfiles = allProfileNames.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex size-full max-w-5xl flex-col lg:pr-2">
|
||||
<Heading as="h4">{t("profiles.title", { ns: "views/settings" })}</Heading>
|
||||
<div className="my-1 text-sm text-muted-foreground">
|
||||
{t("profiles.disabledDescription", { ns: "views/settings" })}
|
||||
</div>
|
||||
|
||||
{/* Enable Profiles Toggle — shown only when no profiles exist */}
|
||||
{!hasProfiles && setProfilesUIEnabled && (
|
||||
<div className="my-6 max-w-xl rounded-lg border border-border/70 bg-card/30 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="profiles-toggle" className="cursor-pointer">
|
||||
{t("profiles.enableSwitch", { ns: "views/settings" })}
|
||||
</Label>
|
||||
<Switch
|
||||
id="profiles-toggle"
|
||||
checked={profilesUIEnabled ?? false}
|
||||
onCheckedChange={setProfilesUIEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profilesUIEnabled && !hasProfiles && (
|
||||
<p className="mb-5 max-w-xl text-sm text-primary-variant">
|
||||
{t("profiles.enabledDescription", { ns: "views/settings" })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Active Profile + Add Profile bar */}
|
||||
{(hasProfiles || profilesUIEnabled) && (
|
||||
<div className="my-4 flex items-center justify-between rounded-lg border border-border/70 bg-card/30 p-4">
|
||||
{hasProfiles && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-primary-variant">
|
||||
{t("profiles.activeProfile", { ns: "views/settings" })}
|
||||
</span>
|
||||
<Select
|
||||
value={activeProfile ?? "__none__"}
|
||||
onValueChange={(v) =>
|
||||
handleActivateProfile(v === "__none__" ? null : v)
|
||||
}
|
||||
disabled={activating}
|
||||
>
|
||||
<SelectTrigger className="">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
{t("profiles.noActiveProfile", { ns: "views/settings" })}
|
||||
</SelectItem>
|
||||
{allProfileNames.map((profile) => {
|
||||
const color = getProfileColor(profile, allProfileNames);
|
||||
return (
|
||||
<SelectItem key={profile} value={profile}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
color.dot,
|
||||
)}
|
||||
/>
|
||||
{profileFriendlyNames?.get(profile) ?? profile}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{activating && <ActivityIndicator className="size-4" />}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<LuPlus className="mr-1.5 size-4" />
|
||||
{t("profiles.addProfile", { ns: "views/settings" })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile List */}
|
||||
{!hasProfiles ? (
|
||||
profilesUIEnabled ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("profiles.noProfiles", { ns: "views/settings" })}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{allProfileNames.map((profile) => {
|
||||
const color = getProfileColor(profile, allProfileNames);
|
||||
const isActive = activeProfile === profile;
|
||||
const cameraData = profileOverviewData[profile] ?? {};
|
||||
const cameras = Object.keys(cameraData).sort();
|
||||
const isExpanded = expandedProfiles.has(profile);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={profile}
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleExpanded(profile)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border",
|
||||
isActive
|
||||
? "border-selected bg-selected/5"
|
||||
: "border-border/70",
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between px-4 py-3 hover:bg-secondary/30">
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<LuChevronRight className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"size-2.5 shrink-0 rounded-full",
|
||||
color.dot,
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{profileFriendlyNames?.get(profile) ?? profile}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameProfile(profile);
|
||||
setRenameValue(
|
||||
profileFriendlyNames?.get(profile) ?? profile,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
{isActive && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs text-primary-variant"
|
||||
>
|
||||
{t("profiles.active", { ns: "views/settings" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{cameras.length > 0
|
||||
? t("profiles.cameraCount", {
|
||||
ns: "views/settings",
|
||||
count: cameras.length,
|
||||
})
|
||||
: t("profiles.noOverrides", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
disabled={deleting && deleteProfile === profile}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteProfile(profile);
|
||||
}}
|
||||
>
|
||||
{deleting && deleteProfile === profile ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{cameras.length > 0 ? (
|
||||
<div className="mx-4 mb-3 ml-11 border-l border-border/50 pl-4">
|
||||
{cameras.map((camera) => {
|
||||
const sections = cameraData[camera];
|
||||
return (
|
||||
<div
|
||||
key={camera}
|
||||
className="flex items-baseline gap-3 py-1.5"
|
||||
>
|
||||
<span className="min-w-[120px] shrink-0 truncate text-sm font-medium">
|
||||
{resolveCameraName(config, camera)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{sections
|
||||
.map((section) =>
|
||||
t(`configForm.sections.${section}`, {
|
||||
ns: "views/settings",
|
||||
defaultValue: section,
|
||||
}),
|
||||
)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 mb-3 ml-11 text-sm text-muted-foreground">
|
||||
{t("profiles.noOverrides", { ns: "views/settings" })}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Profile Dialog */}
|
||||
<Dialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAddDialogOpen(open);
|
||||
if (!open) {
|
||||
addForm.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("profiles.newProfile", { ns: "views/settings" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FormProvider {...addForm}>
|
||||
<form
|
||||
onSubmit={addForm.handleSubmit(handleAddSubmit)}
|
||||
className="space-y-4 py-2"
|
||||
>
|
||||
<NameAndIdFields<AddProfileForm>
|
||||
control={addForm.control}
|
||||
type="profile"
|
||||
nameField="friendly_name"
|
||||
idField="name"
|
||||
nameLabel={t("profiles.friendlyNameLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
idLabel={t("profiles.profileIdLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
idDescription={t("profiles.profileIdDescription", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
placeholderName={t("profiles.profileNamePlaceholder", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAddDialogOpen(false)}
|
||||
disabled={addingProfile}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="select"
|
||||
disabled={
|
||||
addingProfile ||
|
||||
!addForm.watch("friendly_name").trim() ||
|
||||
!addForm.watch("name").trim()
|
||||
}
|
||||
>
|
||||
{addingProfile && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("button.add", { ns: "common" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Profile Confirmation */}
|
||||
<AlertDialog
|
||||
open={!!deleteProfile}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteProfile(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("profiles.deleteProfile", { ns: "views/settings" })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("profiles.deleteProfileConfirm", {
|
||||
ns: "views/settings",
|
||||
profile: deleteProfile
|
||||
? (profileFriendlyNames?.get(deleteProfile) ?? deleteProfile)
|
||||
: "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteProfile();
|
||||
}}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Rename Profile Dialog */}
|
||||
<Dialog
|
||||
open={!!renameProfile}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRenameProfile(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("profiles.renameProfile", { ns: "views/settings" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder={t("profiles.profileNamePlaceholder", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRenameProfile(null)}
|
||||
disabled={renaming}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={handleRename}
|
||||
disabled={renaming || !renameValue.trim()}
|
||||
>
|
||||
{renaming && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,8 +4,16 @@ import type { SectionConfig } from "@/components/config-form/sections";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
||||
import type { PolygonType } from "@/types/canvas";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
import type { ProfileState } from "@/types/profile";
|
||||
import { getSectionConfig } from "@/utils/configUtil";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
@ -20,17 +28,24 @@ export type SettingsPageProps = {
|
||||
level: "global" | "camera",
|
||||
status: SectionStatus,
|
||||
) => void;
|
||||
pendingDataBySection?: Record<string, unknown>;
|
||||
pendingDataBySection?: Record<string, ConfigSectionData>;
|
||||
onPendingDataChange?: (
|
||||
sectionKey: string,
|
||||
cameraName: string | undefined,
|
||||
data: ConfigSectionData | null,
|
||||
) => void;
|
||||
profileState?: ProfileState;
|
||||
/** Callback to delete the current profile's overrides for the current section */
|
||||
onDeleteProfileSection?: (profileName: string) => void;
|
||||
profilesUIEnabled?: boolean;
|
||||
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export type SectionStatus = {
|
||||
hasChanges: boolean;
|
||||
isOverridden: boolean;
|
||||
/** Where the override comes from: "global" = camera overrides global, "profile" = profile overrides base */
|
||||
overrideSource?: "global" | "profile";
|
||||
hasValidationErrors: boolean;
|
||||
};
|
||||
|
||||
@ -56,6 +71,8 @@ export function SingleSectionPage({
|
||||
onSectionStatusChange,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
profileState,
|
||||
onDeleteProfileSection,
|
||||
}: SingleSectionPageProps) {
|
||||
const sectionNamespace =
|
||||
level === "camera" ? "config/cameras" : "config/global";
|
||||
@ -78,6 +95,24 @@ export function SingleSectionPage({
|
||||
? getLocaleDocUrl(resolvedSectionConfig.sectionDocs)
|
||||
: undefined;
|
||||
|
||||
const currentEditingProfile = selectedCamera
|
||||
? (profileState?.editingProfile[selectedCamera] ?? null)
|
||||
: null;
|
||||
|
||||
const profileColor = useMemo(
|
||||
() =>
|
||||
currentEditingProfile && profileState?.allProfileNames
|
||||
? getProfileColor(currentEditingProfile, profileState.allProfileNames)
|
||||
: undefined,
|
||||
[currentEditingProfile, profileState?.allProfileNames],
|
||||
);
|
||||
|
||||
const handleDeleteProfileSection = useCallback(() => {
|
||||
if (currentEditingProfile && onDeleteProfileSection) {
|
||||
onDeleteProfileSection(currentEditingProfile);
|
||||
}
|
||||
}, [currentEditingProfile, onDeleteProfileSection]);
|
||||
|
||||
const handleSectionStatusChange = useCallback(
|
||||
(status: SectionStatus) => {
|
||||
setSectionStatus(status);
|
||||
@ -127,15 +162,44 @@ export function SingleSectionPage({
|
||||
{level === "camera" &&
|
||||
showOverrideIndicator &&
|
||||
sectionStatus.isOverridden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-default border-2 border-selected text-xs text-primary-variant"
|
||||
>
|
||||
{t("button.overridden", {
|
||||
ns: "common",
|
||||
defaultValue: "Overridden",
|
||||
})}
|
||||
</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
||||
sectionStatus.overrideSource === "profile" &&
|
||||
profileColor
|
||||
? profileColor.border
|
||||
: "border-selected",
|
||||
)}
|
||||
>
|
||||
{sectionStatus.overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})
|
||||
: t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{sectionStatus.overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: currentEditingProfile
|
||||
? (profileState?.profileFriendlyNames.get(
|
||||
currentEditingProfile,
|
||||
) ?? currentEditingProfile)
|
||||
: "",
|
||||
})
|
||||
: t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{sectionStatus.hasChanges && (
|
||||
<Badge
|
||||
@ -160,6 +224,17 @@ export function SingleSectionPage({
|
||||
onPendingDataChange={onPendingDataChange}
|
||||
requiresRestart={requiresRestart}
|
||||
onStatusChange={handleSectionStatusChange}
|
||||
profileName={currentEditingProfile ?? undefined}
|
||||
profileFriendlyName={
|
||||
currentEditingProfile
|
||||
? (profileState?.profileFriendlyNames.get(currentEditingProfile) ??
|
||||
currentEditingProfile)
|
||||
: undefined
|
||||
}
|
||||
profileBorderColor={profileColor?.border}
|
||||
onDeleteProfileSection={
|
||||
currentEditingProfile ? handleDeleteProfileSection : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -212,10 +212,10 @@ export default function UiSettingsView() {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Heading as="h4" className="mb-3">
|
||||
{t("general.title")}
|
||||
</Heading>
|
||||
<div className="scrollbar-container mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2">
|
||||
<Heading as="h4" className="mb-3">
|
||||
{t("general.title")}
|
||||
</Heading>
|
||||
<div className="w-full max-w-5xl space-y-6">
|
||||
<SettingsGroupCard title={t("general.liveDashboard.title")}>
|
||||
<div className="space-y-6">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user