Compare commits

...

37 Commits

Author SHA1 Message Date
Josh Hawkins
12e9bb3944 fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.

Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
2026-03-12 11:20:03 -05:00
Josh Hawkins
091e0b80d2 show active profile indicator in desktop status bar 2026-03-12 10:58:16 -05:00
Josh Hawkins
0748766713 hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
2026-03-12 10:56:08 -05:00
Josh Hawkins
611316906a immediately create profiles on backend instead of deferring to Save All 2026-03-12 10:53:53 -05:00
Josh Hawkins
e92fa2b4ba tweak language 2026-03-12 10:47:31 -05:00
Josh Hawkins
8d633423b0 show activity indicator on trash icon while deleting a profile 2026-03-12 10:47:05 -05:00
Josh Hawkins
af0696771a docs 2026-03-12 09:13:20 -05:00
Josh Hawkins
46d91af001 change color order 2026-03-12 09:00:45 -05:00
Josh Hawkins
0f735bea37 use icon only on mobile 2026-03-12 08:57:57 -05:00
Josh Hawkins
5a1ec5d729 remove profile badge in settings and add profiles to main menu 2026-03-12 08:32:23 -05:00
Josh Hawkins
39500b20a0 implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
  button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
  camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
2026-03-11 20:55:56 -05:00
Josh Hawkins
0b3c6ed22e add top-level profiles config section with friendly names 2026-03-11 19:50:31 -05:00
Josh Hawkins
dace54734b more unique colors 2026-03-11 16:21:41 -05:00
Josh Hawkins
210d203fa4 fix mask deletion 2026-03-11 16:04:17 -05:00
Josh Hawkins
5c23555882 implement an update_config method for profile manager 2026-03-11 15:57:27 -05:00
Josh Hawkins
5ec1b1841c refactor profilesview and add dots/border colors when overridden 2026-03-11 15:51:29 -05:00
Josh Hawkins
33e4dddb3e rename profile settings to ui settings 2026-03-11 12:35:45 -05:00
Josh Hawkins
6dd8aa912e ensure profile manager gets updated config 2026-03-11 12:35:34 -05:00
Josh Hawkins
8072c991cd ui tweaks 2026-03-11 12:35:13 -05:00
Josh Hawkins
3cdb40610f fix profile save diff, masksAndZones delete, and config sync 2026-03-11 12:04:35 -05:00
Josh Hawkins
7925d120ae tweak colors and switch 2026-03-11 11:36:15 -05:00
Josh Hawkins
18d413fbee tweaks 2026-03-11 11:35:12 -05:00
Josh Hawkins
98e9e79881 formatting 2026-03-11 11:07:23 -05:00
Josh Hawkins
a0849b104c add profiles enable toggle and improve empty state 2026-03-11 11:05:18 -05:00
Josh Hawkins
096a13bce9 move profile dropdown from section panes to settings header 2026-03-11 11:01:51 -05:00
Josh Hawkins
eccad7aa21 add face_recognition and lpr to profile-eligible sections 2026-03-11 10:35:38 -05:00
Josh Hawkins
cd58329796 fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
2026-03-11 10:35:19 -05:00
Josh Hawkins
379247dee6 profile support for mask and zone editor 2026-03-11 07:16:44 -05:00
Josh Hawkins
6205a9d588 add red dot for any pending changes including profiles 2026-03-09 16:08:50 -05:00
Josh Hawkins
53f80a06a5 i18n 2026-03-09 16:06:22 -05:00
Josh Hawkins
fa49e0e7b1 add active profile badge to settings toolbar 2026-03-09 15:57:24 -05:00
Josh Hawkins
fe7aa2ba3d add profiles summary page with card-based layout and fix backend zone comparison bug 2026-03-09 15:52:26 -05:00
Josh Hawkins
7131acafa6 add per-profile camera enable/disable to Camera Management view 2026-03-09 15:35:35 -05:00
Josh Hawkins
94dbabd0ef add profile section dropdown and wire into camera settings pages 2026-03-09 15:20:50 -05:00
Josh Hawkins
d5dc77daa4 add profileName prop to BaseSection for profile-aware config editing 2026-03-09 15:11:56 -05:00
Josh Hawkins
edf7fcb5b4 add profile state management and save preview support 2026-03-09 15:06:11 -05:00
Josh Hawkins
72b4a4ddad add frontend profile types, color utility, and config save support 2026-03-09 15:00:25 -05:00
36 changed files with 3466 additions and 413 deletions

View File

@ -0,0 +1,179 @@
---
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.
### Defining Profiles
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.

View File

@ -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
```

View File

@ -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`.

View File

@ -94,6 +94,7 @@ const sidebars: SidebarsConfig = {
"Extra Configuration": [
"configuration/authentication",
"configuration/notifications",
"configuration/profiles",
"configuration/ffmpeg_presets",
"configuration/pwa",
"configuration/tls",

View File

@ -158,6 +158,18 @@ 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
)
)
# remove go2rtc stream passwords
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
mode="json", warnings="none", exclude_none=True
@ -229,9 +241,7 @@ def set_profile(request: Request, body: ProfileSetBody):
content={"success": False, "message": err},
status_code=400,
)
request.app.dispatcher.publish(
"profile/state", body.profile or "none", retain=True
)
request.app.dispatcher.publish("profile/state", body.profile or "none", retain=True)
return JSONResponse(
content={
"success": True,
@ -628,6 +638,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

View File

@ -169,14 +169,13 @@ class MqttClient(Communicator):
self.config.active_profile or "none",
retain=True,
)
available_profiles: list[str] = []
for camera in self.config.cameras.values():
for profile_name in camera.profiles:
if profile_name not in available_profiles:
available_profiles.append(profile_name)
available_profiles = [
{"name": name, "friendly_name": defn.friendly_name}
for name, defn in sorted(self.config.profiles.items())
]
self.publish(
"profiles/available",
json.dumps(sorted(available_profiles)),
json.dumps(available_profiles),
retain=True,
)

View File

@ -3,6 +3,10 @@
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
@ -29,6 +33,8 @@ class CameraProfileConfig(FrigateBaseModel):
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

View File

@ -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:

View File

@ -68,6 +68,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
@ -561,6 +562,12 @@ 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",
@ -917,6 +924,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
View 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.",
)

View File

@ -21,6 +21,8 @@ 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,
@ -60,6 +62,34 @@ class ProfileManager:
if section_config is not None:
self._base_configs[cam_name][section] = section_config.model_dump()
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_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.
@ -70,12 +100,10 @@ class ProfileManager:
None on success, or an error message string on failure.
"""
if profile_name is not None:
has_profile = any(
profile_name in cam.profiles
for cam in self.config.cameras.values()
if profile_name not in self.config.profiles:
return (
f"Profile '{profile_name}' is not defined in the profiles section"
)
if not has_profile:
return f"Profile '{profile_name}' not found on any camera"
# Track which camera/section pairs get changed for ZMQ publishing
changed: dict[str, set[str]] = {}
@ -109,9 +137,10 @@ class ProfileManager:
cam_config.enabled = base_enabled
changed.setdefault(cam_name, set()).add("enabled")
# Restore zones
# 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 and cam_config.zones != base_zones:
if base_zones is not None:
cam_config.zones = copy.deepcopy(base_zones)
changed.setdefault(cam_name, set()).add("zones")
@ -195,9 +224,7 @@ class ProfileManager:
if section == "zones":
self.config_updater.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.zones, cam_name
),
CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, cam_name),
cam_config.zones,
)
continue
@ -233,12 +260,12 @@ class ProfileManager:
logger.exception("Failed to load persisted profile")
return None
def get_available_profiles(self) -> list[str]:
"""Get a deduplicated list of all profile names across cameras."""
profiles: set[str] = set()
for cam_config in self.config.cameras.values():
profiles.update(cam_config.profiles.keys())
return sorted(profiles)
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."""

View File

@ -7,6 +7,7 @@ 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
@ -125,6 +126,9 @@ class TestCameraProfileConfig(unittest.TestCase):
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
@ -147,6 +151,37 @@ class TestCameraProfileConfig(unittest.TestCase):
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."""
@ -154,6 +189,10 @@ class TestProfileInConfig(unittest.TestCase):
def setUp(self):
self.base_config = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
"disarmed": {"friendly_name": "Disarmed"},
},
"cameras": {
"front": {
"ffmpeg": {
@ -244,6 +283,10 @@ class TestProfileManager(unittest.TestCase):
def setUp(self):
self.config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
"disarmed": {"friendly_name": "Disarmed"},
},
"cameras": {
"front": {
"ffmpeg": {
@ -295,17 +338,21 @@ class TestProfileManager(unittest.TestCase):
self.manager = ProfileManager(self.config, self.mock_updater)
def test_get_available_profiles(self):
"""Available profiles are collected from all cameras."""
"""Available profiles come from top-level profile definitions."""
profiles = self.manager.get_available_profiles()
assert "armed" in profiles
assert "disarmed" in 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 found" in err
assert "not defined" in err
@patch.object(ProfileManager, "_persist_active_profile")
def test_activate_profile(self, mock_persist):
@ -368,13 +415,12 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile")
def test_activate_profile_disables_camera(self, mock_persist):
"""Profile with enabled=false disables the camera."""
# Add a profile that disables the front camera
from frigate.config.camera.profile import CameraProfileConfig
self.config.profiles["away"] = ProfileDefinitionConfig(
friendly_name="Away"
)
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
enabled=False
)
# Re-create manager to pick up new profile
self.manager = ProfileManager(self.config, self.mock_updater)
assert self.config.cameras["front"].enabled is True
@ -385,8 +431,9 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile")
def test_deactivate_restores_enabled(self, mock_persist):
"""Deactivating a profile restores the camera's base enabled state."""
from frigate.config.camera.profile import CameraProfileConfig
self.config.profiles["away"] = ProfileDefinitionConfig(
friendly_name="Away"
)
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
enabled=False
)
@ -401,9 +448,11 @@ class TestProfileManager(unittest.TestCase):
@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.profile import CameraProfileConfig
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(
@ -423,9 +472,11 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile")
def test_deactivate_restores_zones(self, mock_persist):
"""Deactivating a profile restores base zones."""
from frigate.config.camera.profile import CameraProfileConfig
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(
@ -445,13 +496,15 @@ class TestProfileManager(unittest.TestCase):
@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.profile import CameraProfileConfig
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(
@ -476,12 +529,14 @@ class TestProfileManager(unittest.TestCase):
@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.profile import CameraProfileConfig
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
)
@ -507,12 +562,14 @@ class TestProfileManager(unittest.TestCase):
assert self.mock_updater.publish_update.called
def test_get_profile_info(self):
"""Profile info returns correct structure."""
"""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
assert "armed" in info["profiles"]
names = [p["name"] for p in info["profiles"]]
assert "armed" in names
assert "disarmed" in names
class TestProfilePersistence(unittest.TestCase):

View File

@ -168,6 +168,7 @@
"systemMetrics": "System metrics",
"configuration": "Configuration",
"systemLogs": "System logs",
"profiles": "Profiles",
"settings": "Settings",
"configurationEditor": "Configuration Editor",
"languages": "Languages",

View File

@ -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.",

View File

@ -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 ? (

View File

@ -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,
@ -126,16 +132,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 +181,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 +200,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 +237,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 +284,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 +292,38 @@ 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 editing a profile, show base camera config deep-merged with profile overrides
const rawSectionValue = useMemo(() => {
if (!config) return undefined;
if (effectiveLevel === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath);
const baseValue = get(config.cameras?.[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 +335,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 +446,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 +571,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,7 +594,9 @@ export function ConfigSection({
return;
}
const needsRestart = skipSave
// Profile definition edits never require restart
const needsRestart =
skipSave || profileName
? false
: requiresRestartForOverrides(sanitizedOverrides);
@ -619,6 +693,8 @@ export function ConfigSection({
}
}, [
sectionPath,
effectiveSectionPath,
profileName,
pendingData,
effectiveLevel,
cameraName,
@ -642,8 +718,8 @@ export function ConfigSection({
try {
const basePath =
effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
? `cameras.${cameraName}.${effectiveSectionPath}`
: effectiveSectionPath;
const configData = buildConfigDataForPath(basePath, "");
@ -675,7 +751,7 @@ export function ConfigSection({
);
}
}, [
sectionPath,
effectiveSectionPath,
effectiveLevel,
cameraName,
requiresRestart,
@ -784,7 +860,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 +899,7 @@ export function ConfigSection({
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: sectionConfig.hiddenFields,
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
}}
@ -855,7 +931,8 @@ export function ConfigSection({
{((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") &&
!hasChanges &&
!skipSave && (
!skipSave &&
!profileName && (
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
@ -873,6 +950,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 +1038,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 +1098,32 @@ export function ConfigSection({
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
effectiveLevel === "camera" &&
isOverridden && (
(profileOverridesSection || isOverridden) && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-xs">
{t("button.overridden", {
ns: "common",
defaultValue: "Overridden",
{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 +1161,40 @@ export function ConfigSection({
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
effectiveLevel === "camera" &&
isOverridden && (
(profileOverridesSection || isOverridden) && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className="cursor-default border-2 border-selected text-xs text-primary-variant"
className={cn(
"cursor-default border-2 text-center text-xs text-primary-variant",
overrideSource === "profile" && profileBorderColor
? profileBorderColor
: "border-selected",
)}
>
{t("button.overridden", {
ns: "common",
defaultValue: "Overridden",
{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

View File

@ -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>
</>
)}

View File

@ -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",

View File

@ -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}`,
{
await axios.put(`config/set?${deleteQueryPath}`, {
requires_restart: 0,
},
);
} catch (error) {
});
} 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
// 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,
],
);

View File

@ -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
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 = {
// 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]: {
objects: {
mask: {
[maskId]: maskConfig,
},
},
},
[polygon.camera]: cameraData,
},
},
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
update_topic: updateTopic,
};
} else {
configBody = {
config_data: {
cameras: {
[polygon.camera]: {
objects: {
filters: {
[form_objects]: {
mask: {
[maskId]: maskConfig,
},
},
},
},
},
},
},
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
};
}
axios
.put("config/set", configBody)
@ -259,8 +256,10 @@ export default function ObjectMaskEditPane({
},
);
updateConfig();
// Publish the enabled state through websocket
// 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,
],
);

View File

@ -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,11 +129,21 @@ 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
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,
@ -135,12 +152,13 @@ export default function PolygonItem({
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
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>

View 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>
);
}

View File

@ -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,6 +368,11 @@ export default function ZoneEditPane({
if (renamingZone) {
// rename - delete old zone and replace with new
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;
@ -357,7 +381,7 @@ export default function ZoneEditPane({
polygon.name,
) ?? false;
const {
({
alertQueries: renameAlertQueries,
detectionQueries: renameDetectionQueries,
} = reviewQueries(
@ -367,11 +391,11 @@ export default function ZoneEditPane({
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
));
try {
await axios.put(
`config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`,
`config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
@ -380,7 +404,7 @@ export default function ZoneEditPane({
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch (error) {
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
@ -398,6 +422,20 @@ export default function ZoneEditPane({
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,
});
mutatedConfig = await updateConfig();
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
return;
}
}
}
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
// 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,
],
);

View File

@ -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);

View File

@ -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">
<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 className="flex items-center gap-5">
</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}
/>
);
})()}

View File

@ -14,6 +14,7 @@ export type Polygon = {
friendly_name?: string;
enabled?: boolean;
enabled_in_config?: boolean;
polygonSource?: "base" | "profile" | "override";
};
export type ZoneFormValuesType = {

View File

@ -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,

View File

@ -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,29 @@ export interface CameraConfig {
friendly_name?: string;
};
};
profiles?: Record<string, CameraProfileConfig>;
}
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 +492,8 @@ export interface FrigateConfig {
camera_groups: { [groupName: string]: CameraGroupConfig };
profiles: { [profileName: string]: ProfileDefinitionConfig };
lpr: {
enabled: boolean;
};

33
web/src/types/profile.ts Normal file
View 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;
};

View File

@ -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,44 @@ export const globalCameraDefaultSections = new Set([
"ffmpeg",
]);
// ---------------------------------------------------------------------------
// Profile helpers
// ---------------------------------------------------------------------------
/** 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 +207,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 +238,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 +374,7 @@ export function requiresRestartForFieldPath(
export interface SectionSavePayload {
basePath: string;
sanitizedOverrides: Record<string, unknown>;
sanitizedOverrides: JsonObject;
updateTopic: string | undefined;
needsRestart: boolean;
pendingDataKey: string;
@ -421,23 +480,51 @@ 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) {
if (profileInfo.isProfile) {
const baseValue = get(
config.cameras?.[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 {
rawSectionValue = get(config.cameras?.[cameraName], sectionPath);
}
} else {
rawSectionValue = get(config, sectionPath);
}
@ -446,10 +533,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 +555,7 @@ export function prepareSectionSavePayload(opts: {
? applySchemaDefaults(modifiedSchema, {})
: {};
const effectiveDefaults = getEffectiveDefaultsForSection(
sectionPath,
schemaSection,
level,
modifiedSchema ?? undefined,
schemaDefaults,
@ -466,7 +564,7 @@ export function prepareSectionSavePayload(opts: {
// Build overrides
const overrides = buildOverrides(pendingData, rawData, effectiveDefaults);
const sanitizedOverrides = sanitizeOverridesForSection(
sectionPath,
schemaSection,
level,
overrides,
);
@ -474,7 +572,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 +583,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,8 +597,10 @@ export function prepareSectionSavePayload(opts: {
updateTopic = `config/${sectionPath}`;
}
// Restart detection
const needsRestart = requiresRestartForOverrides(
// Restart detection — profile definitions never need restart
const needsRestart = profileInfo.isProfile
? false
: requiresRestartForOverrides(
sanitizedOverrides,
sectionConfig.restartRequired,
true,
@ -506,7 +608,7 @@ export function prepareSectionSavePayload(opts: {
return {
basePath,
sanitizedOverrides: sanitizedOverrides as Record<string, unknown>,
sanitizedOverrides: sanitizedOverrides as JsonObject,
updateTopic,
needsRestart,
pendingDataKey,

View 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];
}

View File

@ -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,189 @@ 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", { 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>
);
}

View File

@ -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,84 @@ 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;
// Build base zone names set for source tracking
const baseZoneNames = new Set(Object.keys(cameraConfig.zones));
const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {}));
const baseMotionMaskNames = new Set(
Object.keys(cameraConfig.motion.mask || {}),
);
const profileMotionMaskNames = new Set(
Object.keys(profileData?.motion?.mask ?? {}),
);
const baseGlobalObjectMaskNames = new Set(
Object.keys(cameraConfig.objects.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(cameraConfig.zones)) {
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 ?? [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 +320,62 @@ 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(
cameraConfig.motion.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 +391,61 @@ 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(
cameraConfig.objects.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 +461,43 @@ 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(
cameraConfig.objects.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(
cameraConfig.objects.filters,
)
.filter(
([, filterConfig]) =>
filterConfig.mask && Object.keys(filterConfig.mask).length > 0,
@ -316,12 +505,64 @@ 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: finalData.friendly_name,
enabled: finalData.enabled,
enabled_in_config: finalData.enabled_in_config,
objects: [objectName],
points: interpolatePoints(
parseCoordinates(finalData.coordinates),
1,
1,
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: isBase ? dimColor(baseColor) : baseColor,
polygonSource: currentEditingProfile ? source : undefined,
};
objectMaskIndex++;
return [newMask];
},
);
});
// 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,
@ -339,13 +580,15 @@ export default function MasksAndZonesView({
),
distances: [],
isFinished: true,
color: [128, 128, 128],
};
objectMaskIndex++;
return [newMask];
},
);
color: baseColor,
polygonSource: "profile",
});
objectMaskIndex++;
}
}
}
}
}
setAllPolygons([
...zones,
@ -386,7 +629,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 +653,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 +732,7 @@ export default function MasksAndZonesView({
setActiveLine={setActiveLine}
snapPoints={snapPoints}
setSnapPoints={setSnapPoints}
editingProfile={currentEditingProfile}
/>
)}
{editPane == "motion_mask" && (
@ -488,6 +748,7 @@ export default function MasksAndZonesView({
onSave={handleSave}
snapPoints={snapPoints}
setSnapPoints={setSnapPoints}
editingProfile={currentEditingProfile}
/>
)}
{editPane == "object_mask" && (
@ -503,13 +764,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 +837,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/>
))}
</div>
@ -649,6 +913,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/>
))}
</div>
@ -723,6 +989,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/>
))}
</div>

View 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>
);
}

View File

@ -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 && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className="cursor-default border-2 border-selected text-xs text-primary-variant"
className={cn(
"cursor-default border-2 text-center text-xs text-primary-variant",
sectionStatus.overrideSource === "profile" &&
profileColor
? profileColor.border
: "border-selected",
)}
>
{t("button.overridden", {
ns: "common",
defaultValue: "Overridden",
{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>
);

View File

@ -212,10 +212,10 @@ export default function UiSettingsView() {
return (
<div className="flex size-full flex-col">
<Toaster position="top-center" closeButton={true} />
<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="scrollbar-container mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2">
<div className="w-full max-w-5xl space-y-6">
<SettingsGroupCard title={t("general.liveDashboard.title")}>
<div className="space-y-6">