Compare commits

...

7 Commits

Author SHA1 Message Date
dependabot[bot]
ea7a95f7d8
Merge 5d0d89a3f2 into 6fdd65ddb5 2026-05-30 22:54:19 +07:00
Josh Hawkins
6fdd65ddb5
UI tweaks (#23346)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings

* add variants to standardize dialog footer button layouts

* remove text-md

this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html

* make wizard footers consistent with dialog footers

* consistent destructive button style

remove text-white from individual buttons and add it to the variant
2026-05-29 16:00:30 -06:00
Josh Hawkins
4b6fa49449
Miscellaneous fixes (#23335)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* stabilize chart options to stop ApexCharts updateOptions running on every stats tick

* constrain height of export dialog

* stop audio maintainer when deleting a camera

* run face register and recognize API handlers in threadpool
2026-05-29 06:53:17 -06:00
Josh Hawkins
bc65713ae4
Clone camera settings (#23339)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add clone dialog

* i18n

* tweaks

* add to camera management pane

* add e2e test

* optional disable portal prop

* radio and checkbox tweaks

* tweak i18n

* add select all/select none

* fixes

* reset form only on open transition

* unselect all targets for existing camera

* fix test

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

* change source and allow cloning to multiple cameras

* tweak language

* fix overflowing text in save all popover

* tweaks

* fix per label object masks

* use grid for source and target

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

* i18n

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

* restore runtime state in dispatcher

* restore on startup with special case for profile

* add tests

* update docs

* mypy
2026-05-27 12:03:09 -06:00
dependabot[bot]
5d0d89a3f2
Bump mermaid from 11.12.2 to 11.15.0 in /docs
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.12.2 to 11.15.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.2...mermaid@11.15.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 22:56:50 +00:00
113 changed files with 3871 additions and 815 deletions

View File

@ -88,8 +88,18 @@ Configure a "friendly name" for your stream followed by the go2rtc stream name.
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" />, then select your camera.
- Under **Live stream names**, add entries mapping a friendly name to each go2rtc stream name (e.g., `Main Stream` mapped to `test_cam`, `Sub Stream` mapped to `test_cam_sub`).
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" /> and select your camera.
2. Under **Live stream names**, click **Add stream** to add a new entry.
3. In the **Stream name** field, enter a friendly name that will appear in the Live UI's stream dropdown (e.g., `Main Stream`).
4. In the **go2rtc stream** field, open the dropdown and select the go2rtc stream this name should map to (e.g., `test_cam`). The dropdown lists every stream configured under `go2rtc.streams`. If the go2rtc stream hasn't been created yet, you can type the name and choose **Use "..."** to save a custom value.
5. Repeat for each additional stream you want to expose (e.g., `Sub Stream``test_cam_sub`).
6. Use the trash icon on a row to remove a stream, then **Save** the section.
:::tip
Configure your go2rtc streams first under <NavPath path="Settings > System > go2rtc streams" /> so the dropdown is populated with valid options.
:::
</TabItem>
<TabItem value="yaml">
@ -262,7 +272,7 @@ cameras:
Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**:
- **On** — streams are processed normally. Object detection, recording, and Live view are active.
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. This state does **not** persist across Frigate restarts; the camera returns to On after a restart.
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. The Off state persists across Frigate restarts via a `.runtime_state.json` file alongside `config.yml` (see [Runtime toggle persistence](#runtime-toggle-persistence)).
- **Disabled** — the change is saved to your configuration file (`enabled: False`). The camera stops immediately, Frigate stops ffmpeg processes, and all live and historical UI elements for the camera are no longer visible but remains retained on disk. The camera is still listed in **Settings → Global configuration → Camera management** so it can be re-enabled. **A restart of Frigate is required to bring a disabled camera back to On.**
#### Turning a camera on or off
@ -290,6 +300,15 @@ For both Off and Disabled cameras, go2rtc remains active but does not use system
If you want a camera's historical data (review items, tracked objects, footage) to stay accessible in the UI while you stop processing, set the camera to **Off**. If you want the camera fully removed from the Live dashboard, review filters, and other UI surfaces, set it to **Disabled**. The Disabled state still keeps the camera in Camera management so it can be re-enabled later; if you want to remove all traces of a camera including its configuration, delete it via Camera management instead.
#### Runtime toggle persistence
The Live view toggles for **camera on/off**, **detect**, **recordings**, **snapshots**, and **audio detection** — along with the equivalent MQTT `/set` topics — write the new state to `.runtime_state.json` next to your `config.yml`. The file is replayed on Frigate startup so your last-known toggle states survive a restart. Two interactions worth knowing:
- **Settings UI saves win.** When you save a field through **Settings → Global configuration**, the matching entry is cleared from `.runtime_state.json` so the new value in your config file is the durable source.
- **Switching profiles clears all runtime overrides.** Activating or deactivating a [profile](/configuration/profiles) is treated as a deliberate state change, so the file is wiped to avoid stale overrides replaying on top of the new profile.
If you hand-edit `config.yml` while runtime overrides exist, the overrides will still replay on restart. Delete `.runtime_state.json` to reset to the YAML-defined defaults.
### Live player error messages
When your browser runs into problems playing back your camera streams, it will log short error messages to the browser console. They indicate playback, codec, or network issues on the client/browser side, not something server side with Frigate itself. Below are the common messages you may see and simple actions you can take to try to resolve them.

View File

@ -130,6 +130,8 @@ Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integratio
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
Activating or deactivating a profile clears any [runtime toggle overrides](/configuration/live#runtime-toggle-persistence) so the profile's settings aren't silently undone by a stale toggle from before the switch.
## Example: Home / Away Setup
A common use case is having different detection and notification settings based on whether you are home or away. This example below is for a system with two cameras, `front_door` and `indoor_cam`.

View File

@ -368,7 +368,7 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o
### `frigate/<camera_name>/enabled/set`
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is **not** persisted across Frigate restarts — the camera returns to the configured state on restart. To permanently disable a camera, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). To permanently change the configured value, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
### `frigate/<camera_name>/enabled/state`
@ -376,7 +376,7 @@ Topic with current runtime state of processing for a camera. Published values ar
### `frigate/<camera_name>/detect/set`
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/detect/state`
@ -384,7 +384,7 @@ Topic with current state of object detection for a camera. Published values are
### `frigate/<camera_name>/audio/set`
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/audio/state`
@ -392,7 +392,7 @@ Topic with current state of audio detection for a camera. Published values are `
### `frigate/<camera_name>/recordings/set`
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/recordings/state`
@ -400,7 +400,7 @@ Topic with current state of recordings for a camera. Published values are `ON` a
### `frigate/<camera_name>/snapshots/set`
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/snapshots/state`

215
docs/package-lock.json generated
View File

@ -2072,43 +2072,10 @@
"integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==",
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.0.3",
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@colors/colors": {
@ -4504,12 +4471,12 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz",
"integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
"license": "MIT",
"dependencies": {
"langium": "3.3.1"
"@chevrotain/types": "~11.1.1"
}
},
"node_modules/@nodelib/fs.scandir": {
@ -5995,6 +5962,16 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
"integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
"license": "MIT",
"optionalDependencies": {
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1"
}
},
"node_modules/@vercel/oidc": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
@ -7199,32 +7176,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chevrotain": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
"@chevrotain/regexp-to-ast": "11.0.3",
"@chevrotain/types": "11.0.3",
"@chevrotain/utils": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^11.0.0"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -8560,9 +8511,9 @@
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
@ -8796,9 +8747,9 @@
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
"integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
"integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
@ -8973,9 +8924,9 @@
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
@ -10499,6 +10450,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/es6-promise": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
@ -13058,22 +13019,6 @@
"node": ">=6"
}
},
"node_modules/langium": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
"integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.0.3",
"chevrotain-allstar": "~0.3.0",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.0.8"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/latest-version": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz",
@ -13190,9 +13135,9 @@
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@ -13840,31 +13785,32 @@
}
},
"node_modules/mermaid": {
"version": "11.12.2",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz",
"integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.1",
"@mermaid-js/parser": "^0.6.3",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.1.1",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.13",
"dayjs": "^1.11.18",
"dompurify": "^3.2.5",
"katex": "^0.16.22",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"es-toolkit": "^1.45.1",
"katex": "^0.16.25",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^16.2.1",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/methods": {
@ -20210,9 +20156,9 @@
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/roughjs": {
@ -22561,55 +22507,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"license": "MIT"
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",

View File

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

View File

@ -280,7 +280,7 @@ async def create_face(request: Request, name: str):
success response with details about the registration, or an error if face recognition
is not enabled or the image cannot be processed.""",
)
async def register_face(request: Request, name: str, file: UploadFile):
def register_face(request: Request, name: str, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
@ -288,7 +288,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
)
context: EmbeddingsContext = request.app.embeddings
result = None if context is None else context.register_face(name, await file.read())
result = None if context is None else context.register_face(name, file.file.read())
if not isinstance(result, dict):
return JSONResponse(
@ -313,7 +313,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
registered faces in the system. Returns the recognized face name and confidence score,
or an error if face recognition is not enabled or the image cannot be processed.""",
)
async def recognize_face(request: Request, file: UploadFile):
def recognize_face(request: Request, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
@ -321,7 +321,7 @@ async def recognize_face(request: Request, file: UploadFile):
)
context: EmbeddingsContext = request.app.embeddings
result = context.recognize_face(await file.read())
result = context.recognize_face(file.file.read())
if not isinstance(result, dict):
return JSONResponse(

View File

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

View File

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

View File

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

View File

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

View File

@ -94,9 +94,21 @@ class AudioProcessor(FrigateProcess):
self.camera_metrics = camera_metrics
self.config = config
def __stop_audio_thread(self, camera: str) -> None:
thread = self.audio_threads.pop(camera, None)
if thread is None:
return
thread.stop()
thread.join(10)
if thread.is_alive():
self.logger.warning(f"Audio maintainer thread for {camera} is still alive")
else:
self.logger.info(f"Audio maintainer stopped for {camera}")
def run(self) -> None:
self.pre_run_setup(self.config.logger)
audio_threads: dict[str, AudioEventMaintainer] = {}
self.audio_threads: dict[str, AudioEventMaintainer] = {}
threading.current_thread().name = "process:audio_manager"
@ -120,12 +132,13 @@ class AudioProcessor(FrigateProcess):
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.audio,
CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.remove,
],
)
def spawn_if_needed(camera: CameraConfig) -> None:
name = camera.name
if name is None or name in audio_threads:
if name is None or name in self.audio_threads:
return
if not camera.enabled or not camera.audio.enabled:
return
@ -139,7 +152,7 @@ class AudioProcessor(FrigateProcess):
self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type]
)
audio_threads[name] = thread
self.audio_threads[name] = thread
thread.start()
self.logger.info(f"Audio maintainer started for {name}")
@ -148,21 +161,31 @@ class AudioProcessor(FrigateProcess):
self.logger.info(f"Audio processor started (pid: {self.pid})")
# poll for newly added cameras or cameras flipped to audio.enabled at runtime
# poll for newly added/removed cameras or cameras flipped to
# audio.enabled at runtime
while not self.stop_event.wait(timeout=1.0):
config_subscriber.check_for_updates()
updated_topics = config_subscriber.check_for_updates()
# stop maintainers for removed cameras so their ffmpeg process is
# torn down and they stop touching camera_metrics (which the camera
# maintainer has already popped for the removed camera)
for removed_camera in updated_topics.get(
CameraConfigUpdateEnum.remove.name, []
):
self.__stop_audio_thread(removed_camera)
for camera in self.config.cameras.values():
spawn_if_needed(camera)
config_subscriber.stop()
for thread in audio_threads.values():
for thread in self.audio_threads.values():
thread.join(1)
if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10)
for thread in audio_threads.values():
for thread in self.audio_threads.values():
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")
@ -184,6 +207,9 @@ class AudioEventMaintainer(threading.Thread):
self.camera_config = camera
self.camera_metrics = camera_metrics
self.stop_event = stop_event
# per-camera stop signal so a single maintainer can be torn down at
# runtime (e.g. on camera removal) without stopping the whole process
self.camera_stop_event = threading.Event()
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
@ -233,7 +259,11 @@ class AudioEventMaintainer(threading.Thread):
self.was_audio_enabled = camera.audio.enabled
def detect_audio(self, audio: np.ndarray) -> None:
if not self.camera_config.audio.enabled or self.stop_event.is_set():
if (
not self.camera_config.audio.enabled
or self.stop_event.is_set()
or self.camera_stop_event.is_set()
):
return
audio_as_float: np.ndarray = audio.astype(np.float32)
@ -352,11 +382,15 @@ class AudioEventMaintainer(threading.Thread):
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
log_and_restart()
def stop(self) -> None:
"""Signal this maintainer to exit its run loop and clean up."""
self.camera_stop_event.set()
def run(self) -> None:
if self.camera_config.enabled:
self.start_or_restart_ffmpeg()
while not self.stop_event.is_set():
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
# check if there is an updated config
self.config_subscriber.check_for_updates()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -107,7 +107,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormLabel>{t("form.user")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
autoFocus
autoCapitalize="off"
autoCorrect="off"
@ -125,7 +125,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormLabel>{t("form.password")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
type="password"
{...field}
/>

View File

@ -257,7 +257,7 @@ export function ExportCard({
{editName && (
<>
<Input
className="text-md mt-3"
className="mt-3"
type="search"
placeholder={editName?.original}
value={
@ -275,7 +275,6 @@ export function ExportCard({
<DialogFooter>
<Button
aria-label={t("editExport.saveExport")}
size="sm"
variant="select"
disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => submitRename()}

View File

@ -14,7 +14,7 @@ type SettingsGroupCardProps = {
export function SettingsGroupCard({ title, children }: SettingsGroupCardProps) {
return (
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
{title}
</div>
{children}

View File

@ -48,7 +48,7 @@ export default function ChatSettings({
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">{t("settings.show_stats.title")}</div>
<div>{t("settings.show_stats.title")}</div>
<div className="text-xs text-muted-foreground">
{t("settings.show_stats.desc")}
</div>
@ -77,7 +77,7 @@ export default function ChatSettings({
<DropdownMenuSeparator />
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
<Label htmlFor="auto-scroll" className="cursor-pointer">
{t("settings.auto_scroll.title")}
</Label>
<div className="text-xs text-muted-foreground">

View File

@ -485,7 +485,7 @@ export default function ClassificationModelEditDialog({
<FormControl>
<div className="flex items-center gap-2">
<Input
className="text-md h-8"
className="h-8"
placeholder={t(
"wizard.step1.classPlaceholder",
)}

View File

@ -214,7 +214,7 @@ export default function Step1NameAndDefine({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder={t("wizard.step1.namePlaceholder")}
{...field}
/>
@ -457,7 +457,7 @@ export default function Step1NameAndDefine({
<FormControl>
<div className="flex items-center gap-2">
<Input
className="text-md h-8"
className="h-8"
placeholder={t("wizard.step1.classPlaceholder")}
{...field}
/>
@ -489,7 +489,7 @@ export default function Step1NameAndDefine({
</form>
</Form>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>

View File

@ -458,7 +458,7 @@ export default function Step2StateArea({
</div>
</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>

View File

@ -1,4 +1,4 @@
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useState, useEffect, useCallback, useMemo } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
@ -540,7 +540,7 @@ export default function Step3ChooseExamples({
</AlertDialogCancel>
<AlertDialogAction
onClick={doRefresh}
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
>
{t("button.continue", { ns: "common" })}
</AlertDialogAction>
@ -693,7 +693,7 @@ export default function Step3ChooseExamples({
)}
{!isTraining && (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={handleBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>

View File

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

View File

@ -10,7 +10,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
@ -491,7 +490,6 @@ export default function NotificationsSettingsExtras({
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
<div className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && (
@ -521,7 +519,7 @@ export default function NotificationsSettingsExtras({
<FormControl>
<Input
id="notification-email"
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder={t(
"notification.email.placeholder",
)}
@ -788,7 +786,7 @@ export function CameraNotificationSwitch({
)}
<div className="flex flex-col">
<CameraNameLabel
className="text-md cursor-pointer text-primary smart-capitalize"
className="cursor-pointer text-primary smart-capitalize"
htmlFor="camera"
camera={camera}
/>

View File

@ -32,7 +32,7 @@ import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
import { useSectionSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import Heading from "@/components/ui/heading";
import get from "lodash/get";
@ -1236,7 +1236,7 @@ export function ConfigSection({
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => {
onDeleteProfileSection?.();
setIsDeleteProfileDialogOpen(false);

View File

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

View File

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

View File

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

View File

@ -371,7 +371,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
key={group.groupKey}
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
>
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
{group.label}
</div>
<div className="space-y-6">

View File

@ -79,7 +79,7 @@ export function ArrayAsTextWidget(props: WidgetProps) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
value={text}
disabled={disabled || readonly}
rows={(options.rows as number) || 3}

View File

@ -124,7 +124,7 @@ export function CameraPathWidget(props: WidgetProps) {
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
className={cn("text-md", canToggle ? "pr-10" : undefined)}
className={cn(canToggle ? "pr-10" : undefined)}
type="text"
value={displayValue}
disabled={disabled || readonly}

View File

@ -26,7 +26,7 @@ export function TextWidget(props: WidgetProps) {
return (
<Input
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
type="text"
value={value ?? ""}
disabled={disabled || readonly}

View File

@ -26,7 +26,7 @@ export function TextareaWidget(props: WidgetProps) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}

View File

@ -15,6 +15,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
@ -847,7 +848,7 @@ export function CameraGroupEdit({
<FormLabel>{t("group.name.label")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder={t("group.name.placeholder")}
{...field}
/>
@ -973,10 +974,9 @@ export function CameraGroupEdit({
<Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0">
<DialogFooter className="py-5 md:pb-0">
<Button
type="button"
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
>
@ -985,7 +985,6 @@ export function CameraGroupEdit({
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
@ -998,7 +997,7 @@ export function CameraGroupEdit({
t("button.save", { ns: "common" })
)}
</Button>
</div>
</DialogFooter>
</form>
</Form>
);

View File

@ -40,7 +40,7 @@ export function LogSettingsButton({
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">{t("filter")}</div>
<div>{t("filter")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("logSettings.filterBySeverity")}
</div>
@ -53,7 +53,7 @@ export function LogSettingsButton({
<DropdownMenuSeparator />
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">{t("logSettings.loading.title")}</div>
<div>{t("logSettings.loading.title")}</div>
<div className="mt-2.5 flex flex-col gap-2.5">
<div className="space-y-1 text-xs text-muted-foreground">
{t("logSettings.loading.desc")}

View File

@ -56,18 +56,25 @@ export function CameraLineGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
return formatUnixTimestampToDateTime(
updateTimes[Math.round(val as number)],
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.round(val as number)];
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {
@ -211,18 +218,25 @@ export function EventsPerSecondsLineGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
return formatUnixTimestampToDateTime(
updateTimes[Math.round(val as number) - 1],
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.round(val as number) - 1];
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {

View File

@ -61,6 +61,11 @@ export function ThresholdBarGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
const dateIndex = Math.round(val as number);
@ -69,16 +74,18 @@ export function ThresholdBarGraph({
if (dateIndex < 0) {
timeOffset = 5 * Math.abs(dateIndex);
}
return formatUnixTimestampToDateTime(
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.max(1, dateIndex) - 1] - timeOffset;
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {

View File

@ -119,7 +119,7 @@ export default function IconPicker({
placeholder={t("iconPicker.search.placeholder", {
ns: "components/icons",
})}
className="text-md mb-3 md:text-sm"
className="mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>

View File

@ -696,7 +696,7 @@ export default function InputWithTags({
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-32"
className="h-9 pr-32"
placeholder={t("placeholder.search")}
/>
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">

View File

@ -112,11 +112,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
</span>
</div>
<FormControl>
<Input
className="text-md"
placeholder={placeholderName}
{...field}
/>
<Input placeholder={placeholderName} {...field} />
</FormControl>
{nameDescription && (
<FormDescription>{nameDescription}</FormDescription>
@ -134,7 +130,6 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
<FormControl>
<Input
className="text-md"
placeholder={placeholderId}
disabled={idDisabled}
{...field}

View File

@ -69,7 +69,6 @@ export function SaveSearchDialog({
</DialogHeader>
<Input
value={searchName}
className="text-md"
onChange={(e) => setSearchName(e.target.value)}
placeholder={t("search.saveSearch.placeholder")}
/>
@ -88,7 +87,6 @@ export function SaveSearchDialog({
<Button
onClick={handleSave}
variant="select"
className="mb-2 md:mb-0"
aria-label={t("search.saveSearch.button.save.label")}
>
{t("button.save", { ns: "common" })}

View File

@ -77,7 +77,7 @@ export default function TextEntry({
<FormControl>
<Input
{...field}
className="text-md w-full"
className="w-full"
placeholder={placeholder}
type="text"
/>

View File

@ -276,7 +276,7 @@ export default function LiveContextMenu({
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<div className="flex flex-col items-start gap-1 py-1 pl-2">
<div className="text-md text-primary-variant smart-capitalize">
<div className="text-primary-variant smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (

View File

@ -213,36 +213,30 @@ export default function CreateRoleDialog({
<FormMessage />
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>

View File

@ -433,36 +433,30 @@ export default function CreateTriggerDialog({
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>

View File

@ -411,36 +411,30 @@ export default function CreateUserDialog({
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>

View File

@ -144,7 +144,7 @@ export function CustomTimeSelector({
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
@ -210,7 +210,7 @@ export function CustomTimeSelector({
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endClock}

View File

@ -113,19 +113,14 @@ export function DebugReplayContent({
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
>
<DialogFooter className="mt-3 sm:mt-0">
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className={isDesktop ? "" : "w-full"}
variant="select"
disabled={isStarting}
onClick={() => {

View File

@ -70,38 +70,31 @@ export default function DeleteRoleDialog({
</div>
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex flex-1"
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -43,36 +43,30 @@ export default function DeleteTriggerDialog({
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={onDelete}
disabled={isLoading}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.delete", { ns: "common" })}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={onDelete}
disabled={isLoading}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.delete", { ns: "common" })}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -46,27 +46,21 @@ export default function DeleteUserDialog({
</p>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -105,13 +105,15 @@ export default function EditRoleCamerasDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
className="space-y-5 pt-2"
>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="space-y-3">
<div>
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
</div>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
@ -159,36 +161,30 @@ export default function EditRoleCamerasDialog({
<FormMessage />
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>

View File

@ -287,7 +287,7 @@ export default function ExportDialog({
<Content
className={
isDesktop
? "sm:rounded-lg md:rounded-2xl"
? "scrollbar-container max-h-[90dvh] overflow-y-auto sm:rounded-lg md:rounded-2xl"
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl"
}
>
@ -794,7 +794,6 @@ export function ExportContent({
)}
<Input
className="text-md"
type="search"
placeholder={t("export.name.placeholder")}
value={name}
@ -835,13 +834,11 @@ export function ExportContent({
{selectedCaseId === "new" && (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={singleNewCaseName}
onChange={(e) => setSingleNewCaseName(e.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={singleNewCaseDescription}
onChange={(e) =>
@ -988,7 +985,6 @@ export function ExportContent({
{t("export.multiCamera.nameLabel")}
</Label>
<Input
className="text-md"
type="search"
placeholder={t("export.multiCamera.namePlaceholder")}
value={name}
@ -1028,13 +1024,11 @@ export function ExportContent({
{batchCaseSelection === "new" && (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) =>
@ -1049,20 +1043,15 @@ export function ExportContent({
</Tabs>
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
>
<DialogFooter className="mt-3 sm:mt-0">
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
{activeTab === "export" ? (
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.selectOrExport")}
variant="select"
disabled={isStartingExport}
@ -1086,12 +1075,10 @@ export function ExportContent({
</Button>
) : (
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.multiCamera.exportButton", {
count: selectedCameraCount,
})}
variant="select"
size="sm"
disabled={!canStartBatchExport}
onClick={() => void startBatchExport()}
>

View File

@ -85,7 +85,7 @@ export default function ImagePicker({
<Input
type="text"
placeholder={t("imagePicker.search.placeholder")}
className="text-md mb-3 md:text-sm"
className="mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);

View File

@ -290,7 +290,6 @@ export default function MultiExportDialog({
const newCaseInputs = (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
@ -298,7 +297,6 @@ export default function MultiExportDialog({
autoFocus={isDesktop}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) => setNewCaseDescription(event.target.value)}
@ -344,11 +342,7 @@ export default function MultiExportDialog({
const footer = (
<>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isExporting}
>
<Button onClick={() => handleOpenChange(false)} disabled={isExporting}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
@ -380,7 +374,7 @@ export default function MultiExportDialog({
</DialogDescription>
</DialogHeader>
{body}
<DialogFooter className="gap-2">{footer}</DialogFooter>
<DialogFooter>{footer}</DialogFooter>
</DialogContent>
</Dialog>
);
@ -399,7 +393,7 @@ export default function MultiExportDialog({
</DrawerDescription>
</DrawerHeader>
{body}
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
<DialogFooter className="mt-4">{footer}</DialogFooter>
</DrawerContent>
</Drawer>
);

View File

@ -117,30 +117,23 @@ export default function RoleChangeDialog({
</Select>
</div>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -450,36 +450,30 @@ export default function SetPasswordDialog({
</div>
)}
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>

View File

@ -196,21 +196,16 @@ export function ShareTimestampContent({
{isDesktop && <Separator className="my-4 bg-secondary" />}
<DialogFooter
className={cn("mt-4", !isDesktop && "flex flex-col-reverse gap-2")}
>
<DialogFooter className="mt-3 sm:mt-0">
{onCancel && (
<Button
className={cn(!isDesktop && "w-full")}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
)}
<Button
className={cn(!isDesktop && "w-full")}
variant="select"
onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))}
>
@ -338,7 +333,7 @@ function CustomTimestampSelector({
/>
<div className="my-3 h-px w-full bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="shareTimestamp"
type="time"
value={clock}

View File

@ -145,7 +145,7 @@ export function AnnotationSettingsPane({
return (
<div className="p-4">
<div className="text-md mb-2">
<div className="mb-2">
{t("trackingDetails.annotationSettings.title")}
</div>

View File

@ -131,7 +131,7 @@ export default function CreateFaceWizardDialog({
forbiddenPattern={/#/}
forbiddenErrorMessage={t("description.nameCannotContainHash")}
>
<div className="flex justify-end py-2">
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
@ -144,7 +144,7 @@ export default function CreateFaceWizardDialog({
{t("steps.description.uploadFace", { name })}
</div>
<ImageEntry onSave={onUploadImage}>
<div className="flex justify-end py-2">
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
@ -173,7 +173,7 @@ export default function CreateFaceWizardDialog({
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
<div className="flex justify-end">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
variant="select"
onClick={() => {

View File

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

View File

@ -1569,7 +1569,7 @@ function ObjectDetailsTab({
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
className="flex-1"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {
@ -1706,7 +1706,7 @@ function ObjectDetailsTab({
) : (
<div className="flex flex-col gap-2">
<Textarea
className="text-md h-32 md:text-sm"
className="h-32 md:text-sm"
placeholder={t("details.description.placeholder")}
value={desc}
onChange={(e) => setDesc(e.target.value)}

View File

@ -821,7 +821,7 @@ export function TrackingDetails({
</div>
<div className="flex items-center gap-2">
<span className="capitalize">{label}</span>
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
<div className="flex items-center text-xs text-secondary-foreground">
{formattedStart ?? ""}
{event.end_time != null ? (
<> - {formattedEnd}</>
@ -1072,7 +1072,7 @@ function LifecycleIconRow({
<div className="ml-2 flex w-full min-w-0 flex-1">
<div className="flex flex-col">
<div className="text-md flex items-start break-words text-left">
<div className="flex items-start break-words text-left">
{getLifecycleItemDescription(item)}
</div>
{/* Only show Score/Ratio/Area for object events, not for audio (heard) or manual API (external) events */}

View File

@ -121,28 +121,22 @@ export default function DeleteCameraDialog({
))}
</SelectContent>
</Select>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={handleClose}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={handleDelete}
disabled={!selectedCamera}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={handleClose}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={handleDelete}
disabled={!selectedCamera}
>
{t("button.delete", { ns: "common" })}
</Button>
</DialogFooter>
</>
) : (
@ -173,39 +167,31 @@ export default function DeleteCameraDialog({
{t("cameraManagement.deleteCameraDialog.deleteExports")}
</Label>
</div>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.back", { ns: "common" })}
onClick={handleBack}
type="button"
disabled={isDeleting}
>
{t("button.back", { ns: "common" })}
</Button>
<Button
variant="destructive"
className="flex flex-1 text-white"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
{t(
"cameraManagement.deleteCameraDialog.confirmButton",
)}
</span>
</div>
) : (
t("cameraManagement.deleteCameraDialog.confirmButton")
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.back", { ns: "common" })}
onClick={handleBack}
type="button"
disabled={isDeleting}
>
{t("button.back", { ns: "common" })}
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
{t("cameraManagement.deleteCameraDialog.confirmButton")}
</span>
</div>
) : (
t("cameraManagement.deleteCameraDialog.confirmButton")
)}
</Button>
</DialogFooter>
</>
)}

View File

@ -173,7 +173,7 @@ export function FrigatePlusDialog({
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
className="flex-1"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {

View File

@ -7,9 +7,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import FilterSwitch from "@/components/filter/FilterSwitch";
@ -77,7 +75,7 @@ export default function MultiSelectDialog({
/>
))}
</div>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel")}
</Button>

View File

@ -144,18 +144,13 @@ export default function OptionAndInputDialog({
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input
className="text-md"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Textarea
className="text-md"
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
rows={2}
@ -164,10 +159,9 @@ export default function OptionAndInputDialog({
</div>
)}
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
setOpen(false);

View File

@ -349,7 +349,7 @@ function TimeRangeFilterContent({
</PopoverTrigger>
<PopoverContent className="flex flex-row items-center justify-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={selectedAfterHour}
@ -389,7 +389,7 @@ function TimeRangeFilterContent({
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={

View File

@ -9,8 +9,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
type TextEntryDialogProps = {
@ -63,7 +61,7 @@ export default function TextEntryDialog({
forbiddenPattern={forbiddenPattern}
forbiddenErrorMessage={forbiddenErrorMessage}
>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<DialogFooter>
<Button
type="button"
disabled={isSaving}

View File

@ -443,7 +443,7 @@ export default function LivePlayer({
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
<div className="text-md">{t("streamOffline.title")}</div>
<div>{t("streamOffline.title")}</div>
<TbExclamationCircle className="size-6" />
{!isCompact && (
<p className="text-center text-sm">

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
@ -327,7 +326,6 @@ export default function MotionMaskEditPane({
return (
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length
? t("masksAndZones.motionMasks.edit")

View File

@ -31,7 +31,6 @@ import { FaCheckCircle } from "react-icons/fa";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
@ -335,7 +334,6 @@ export default function ObjectMaskEditPane({
return (
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length
? t("masksAndZones.objectMasks.edit")

View File

@ -24,12 +24,12 @@ import { toRGBColorString } from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { buttonVariants } from "@/components/ui/button";
import { Trans, useTranslation } from "react-i18next";
import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils";
@ -368,8 +368,6 @@ export default function PolygonItem({
return (
<>
<Toaster position="top-center" closeButton={true} />
<div
key={index}
className="transition-background relative my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100"
@ -511,7 +509,7 @@ export default function PolygonItem({
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleDelete}
>
{polygon.polygonSource === "override"

View File

@ -59,9 +59,7 @@ export default function ExploreSettings({
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">
{t("explore.settings.defaultView.title")}
</div>
<div>{t("explore.settings.defaultView.title")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("explore.settings.defaultView.desc")}
</div>
@ -97,9 +95,7 @@ export default function ExploreSettings({
<DropdownMenuSeparator />
<div className="flex w-full flex-col space-y-4">
<div className="space-y-0.5">
<div className="text-md">
{t("explore.settings.gridColumns.title")}
</div>
<div>{t("explore.settings.gridColumns.title")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("explore.settings.gridColumns.desc")}
</div>
@ -162,9 +158,7 @@ export function SearchTypeContent({
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="space-y-0.5">
<div className="text-md">
{t("explore.settings.searchSource.label")}
</div>
<div>{t("explore.settings.searchSource.label")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("explore.settings.searchSource.desc")}
</div>

View File

@ -24,7 +24,6 @@ import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
import axios from "axios";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import ActivityIndicator from "../indicators/activity-indicator";
@ -628,7 +627,6 @@ export default function ZoneEditPane({
return (
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length
? t("masksAndZones.zones.edit")
@ -709,7 +707,7 @@ export default function ZoneEditPane({
<FormLabel>{t("masksAndZones.zones.inertia.title")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="3"
{...field}
/>
@ -734,7 +732,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0"
{...field}
/>
@ -864,7 +862,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(1)}
onBlur={() => setActiveLine(undefined)}
@ -891,7 +889,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(2)}
onBlur={() => setActiveLine(undefined)}
@ -918,7 +916,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(3)}
onBlur={() => setActiveLine(undefined)}
@ -945,7 +943,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(4)}
onBlur={() => setActiveLine(undefined)}
@ -972,7 +970,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
/>
</FormControl>

View File

@ -171,7 +171,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
{...field}
/>
@ -192,7 +192,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder="192.168.1.100"
{...field}
/>
@ -212,7 +212,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
{...field}
/>
@ -233,7 +233,7 @@ export default function Step1NameCamera({
<FormControl>
<div className="relative">
<Input
className="text-md h-8 pr-10"
className="h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
@ -316,7 +316,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
type="text"
{...field}
placeholder="80"
@ -440,7 +440,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder="rtsp://username:password@host:port/path"
{...field}
/>
@ -455,7 +455,7 @@ export default function Step1NameCamera({
</form>
</Form>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>

View File

@ -626,7 +626,7 @@ function ProbeFooterButtons({
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
@ -649,7 +649,7 @@ function ProbeFooterButtons({
return (
<div className="space-y-4">
<div className="text-sm text-destructive">{probeError}</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
@ -670,7 +670,7 @@ function ProbeFooterButtons({
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
if (mode === "manual") {
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
@ -707,7 +707,7 @@ function ProbeFooterButtons({
// Default probe footer
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>

View File

@ -731,7 +731,7 @@ export default function Step3StreamConfig({
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}

View File

@ -490,7 +490,7 @@ export default function Step4Validation({
</div>
</div>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}

View File

@ -176,20 +176,15 @@ export default function Step1NameAndType({
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1"
>
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={!form.formState.isValid}
className="flex-1"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>

View File

@ -109,20 +109,15 @@ export default function Step2ConfigureData({
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={!form.formState.isValid}
className="flex-1"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>

View File

@ -181,20 +181,15 @@ export default function Step3ThresholdAndActions({
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isLoading}
className="flex-1"
className="sm:flex-1"
variant="select"
>
{isLoading && <ActivityIndicator className="mr-2 size-5" />}

View File

@ -1,6 +1,8 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
@ -59,15 +61,35 @@ const AlertDialogHeader = ({
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const alertDialogFooterVariants = cva(
"flex flex-col-reverse gap-2 sm:flex-row",
{
variants: {
variant: {
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
// [&>button] only targets real button children, so non-button siblings are untouched.
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
// context content (text/popover) alongside actions: space-between on desktop.
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
split: "flex-col sm:items-center sm:justify-between",
// alignment only; never touches children. Escape hatch for unusual content.
plain: "sm:justify-end",
},
},
defaultVariants: {
variant: "actions",
},
},
);
const AlertDialogFooter = ({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
}: React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof alertDialogFooterVariants>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
className={cn(alertDialogFooterVariants({ variant }), className)}
{...props}
/>
);

View File

@ -11,8 +11,7 @@ const buttonVariants = cva(
variant: {
default: "bg-secondary text-primary hover:bg-secondary/80",
select: "bg-selected text-selected-foreground hover:bg-opacity-90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-primary text-primary-foreground hover:bg-primary/90",

View File

@ -1,5 +1,6 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useHistoryBack } from "@/hooks/use-history-back";
@ -107,15 +108,32 @@ const DialogHeader = ({
);
DialogHeader.displayName = "DialogHeader";
const dialogFooterVariants = cva("flex flex-col-reverse gap-2 sm:flex-row", {
variants: {
variant: {
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
// [&>button] only targets real button children, so non-button siblings are untouched.
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
// context content (text/popover) alongside actions: space-between on desktop.
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
split: "flex-col sm:items-center sm:justify-between",
// alignment only; never touches children. Escape hatch for unusual content.
plain: "sm:justify-end",
},
},
defaultVariants: {
variant: "actions",
},
});
const DialogFooter = ({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
}: React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof dialogFooterVariants>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
className={cn(dialogFooterVariants({ variant }), className)}
{...props}
/>
);

View File

@ -608,7 +608,6 @@ function Exports() {
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<Button
className="text-white"
aria-label="Delete Export"
variant="destructive"
onClick={() => onHandleDelete()}
@ -658,7 +657,6 @@ function Exports() {
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<Button
className="text-white"
variant="destructive"
onClick={() => void handleDeleteCase()}
>
@ -744,7 +742,7 @@ function Exports() {
</Button>
)}
<Input
className="text-md w-full bg-muted md:w-1/2"
className="w-full bg-muted md:w-1/2"
placeholder={t("search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
@ -1277,8 +1275,8 @@ function CaseEditorDialog({
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
<DialogFooter>
<Button onClick={onClose}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
@ -1295,7 +1293,7 @@ function CaseEditorDialog({
? t("button.save", { ns: "common" })
: t("toolbar.newCase")}
</Button>
</div>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
@ -1427,13 +1425,12 @@ function CaseAddExportDialog({
)}
</div>
</div>
<DialogFooter className="flex-row justify-end gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
<DialogFooter>
<Button onClick={onClose}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
size="sm"
disabled={selectedIds.length === 0 || isAdding}
onClick={() => void handleAdd()}
>

View File

@ -567,7 +567,6 @@ function LibrarySelector({
</Button>
<Button
variant="destructive"
className="text-white"
onClick={() => {
if (confirmDelete) {
handleDeleteFace(confirmDelete);

View File

@ -332,7 +332,7 @@ export default function Replay() {
<Button
variant="destructive"
size="sm"
className="flex items-center gap-2 text-white"
className="flex items-center gap-2"
disabled={isStopping}
>
{isStopping && <ActivityIndicator className="size-4" />}
@ -355,10 +355,7 @@ export default function Replay() {
</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
className={cn(
buttonVariants({ variant: "destructive" }),
"text-white",
)}
className={cn(buttonVariants({ variant: "destructive" }))}
>
{t("page.confirmStop.confirm")}
</AlertDialogAction>
@ -687,7 +684,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
</div>
</div>
<div className="flex w-8/12 flex-row items-center justify-end">
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.score", {
@ -697,7 +694,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
{obj.score ? (obj.score * 100).toFixed(1).toString() : "-"}%
</div>
</div>
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.ratio", {
@ -707,7 +704,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
</div>
</div>
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.area", {

View File

@ -1616,7 +1616,7 @@ export default function Settings() {
if (isMobile) {
return (
<>
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
{!contentMobileOpen && (
<div
key={`mobile-menu-${selectedCamera}`}
@ -1872,7 +1872,7 @@ export default function Settings() {
return (
<div className="flex h-full flex-col">
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<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">

View File

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

View File

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

View File

@ -668,7 +668,6 @@ function LibrarySelector({
</Button>
<Button
variant="destructive"
className="text-white"
onClick={() => {
if (confirmDelete) {
handleDeleteCategory(confirmDelete);

View File

@ -1389,9 +1389,8 @@ function MotionReview({
selectedCells={pendingFilterCells}
onCellsChange={setPendingFilterCells}
/>
<DialogFooter className="justify-end gap-1">
<DialogFooter>
<Button
variant="outline"
disabled={pendingFilterCells.size === 0}
onClick={() => {
setPendingFilterCells(new Set());
@ -1432,9 +1431,7 @@ function MotionReview({
<div className="space-y-4 py-2">
{!isDesktop && (
<div className="space-y-1">
<div className="text-md">
{t("motionPreviews.mobileSettingsTitle")}
</div>
<div>{t("motionPreviews.mobileSettingsTitle")}</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.mobileSettingsDesc")}
</div>
@ -1443,9 +1440,7 @@ function MotionReview({
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">
{t("motionPreviews.speed")}
</div>
<div>{t("motionPreviews.speed")}</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.speedDesc")}
</div>
@ -1474,7 +1469,7 @@ function MotionReview({
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">{t("motionPreviews.dim")}</div>
<div>{t("motionPreviews.dim")}</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.dimDesc")}
</div>

View File

@ -784,7 +784,7 @@ export default function LiveCameraView({
transcription != null && (
<div
ref={transcriptionRef}
className="text-md scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
className="scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
>
{transcription}
</div>

View File

@ -630,7 +630,7 @@ function SearchRangeSelector({
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
@ -696,7 +696,7 @@ function SearchRangeSelector({
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endClock}

View File

@ -1052,7 +1052,6 @@ export default function MotionSearchView({
</div>
<Button
variant="destructive"
className="text-white"
size="sm"
onClick={() => {
void cancelMotionSearchJob(jobId, jobCamera);

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
import { Toaster } from "@/components/ui/sonner";
import useSWR from "swr";
import Heading from "@/components/ui/heading";
import { User } from "@/types/user";
@ -790,7 +789,6 @@ export default function AuthenticationView({
return (
<div className="flex size-full flex-col">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
{section === "users" && UsersSection}
{section === "roles" && RolesSection}

Some files were not shown because too many files have changed in this diff Show More