mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 11:51:53 +03:00
Compare commits
7 Commits
e2318023a7
...
a009b8d7f4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a009b8d7f4 | ||
|
|
37ea6b46b5 | ||
|
|
8203e39b7f | ||
|
|
282e70d4bf | ||
|
|
a7df17cc61 | ||
|
|
c79ca9838f | ||
|
|
551d3b0812 |
@ -655,11 +655,6 @@ snapshots:
|
||||
retain:
|
||||
# Required: Default retention days (default: shown below)
|
||||
default: 10
|
||||
# Optional: Mode for retention. (default: shown below)
|
||||
# all - save all snapshots regardless of activity
|
||||
# motion - save snapshots for any detected motion
|
||||
# active_objects - save snapshots for active/moving objects
|
||||
mode: motion
|
||||
# Optional: Per object retention days
|
||||
objects:
|
||||
person: 15
|
||||
|
||||
@ -54,7 +54,7 @@ The ffmpeg process for capturing audio will be a separate connection to the came
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add an input with the `audio` role pointing to a stream that includes audio.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add an input with the `audio` role pointing to a stream that includes audio.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@ -24,12 +24,14 @@ Each role can only be assigned to one input per camera. The options for roles ar
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | ------------------------------------------------------------------- |
|
||||
| **Camera inputs** | List of input stream definitions (paths and roles) for this camera. |
|
||||
|
||||
For each input you can choose its source: select **Restream (go2rtc)** to pick an existing [go2rtc stream](restream.md) from a dropdown (Frigate uses the `rtsp://127.0.0.1:8554/<stream>` path and `preset-rtsp-restream` input args for that input automatically), or **Manual input path** to type the stream URL directly.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
|
||||
|
||||
| Field | Description |
|
||||
|
||||
@ -33,7 +33,7 @@ Select the appropriate hwaccel preset for your hardware.
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to the appropriate preset for your hardware.
|
||||
2. To override for a specific camera, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and set **Hardware acceleration arguments** for that camera.
|
||||
2. To override for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and set **Hardware acceleration arguments** for that camera.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@ -85,7 +85,7 @@ VAAPI supports automatic profile selection so it will work automatically with bo
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -105,7 +105,7 @@ ffmpeg:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -123,7 +123,7 @@ ffmpeg:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -178,7 +178,7 @@ VAAPI supports automatic profile selection so it will work automatically with bo
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -237,7 +237,7 @@ Using `preset-nvidia` ffmpeg will automatically select the necessary profile for
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -300,7 +300,7 @@ If you are using the HA App, you may need to use the full access variant and tur
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -420,7 +420,7 @@ For example, for H264 video, you'll select `preset-jetson-h264`.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -452,7 +452,7 @@ Set the FFmpeg hwaccel preset to enable hardware video processing.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -519,7 +519,7 @@ Set the FFmpeg hwaccel args to enable hardware video processing.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@ -363,7 +363,7 @@ An example configuration for a dedicated LPR camera using a `license_plate`-dete
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" /> and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available).
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add your camera streams.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
|
||||
|
||||
@ -475,7 +475,7 @@ Navigate to <NavPath path="Settings > Camera configuration > License plate recog
|
||||
| **Enable LPR** | Set to on |
|
||||
| **Enhancement level** | Set to `3` (optional — enhances the image before trying to recognize characters) |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add your camera streams.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
|
||||
|
||||
|
||||
@ -432,3 +432,5 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
roles:
|
||||
- detect
|
||||
```
|
||||
|
||||
The same applies to your `record` stream: if its aspect ratio differs from your `detect` stream, your recordings will appear in a different shape than the live view. For consistent framing across live view and recordings, use the same aspect ratio for all of a camera's streams (the resolution can still differ).
|
||||
|
||||
@ -61,7 +61,7 @@ Configure the go2rtc stream and point the camera inputs at the local restream.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`).
|
||||
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera. For each input, choose **Restream (go2rtc)** and pick the matching stream from the dropdown — Frigate uses the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`) and the `preset-rtsp-restream` input args for that input automatically. (Choose **Manual input path** instead to type a URL directly.)
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -111,7 +111,7 @@ Two connections are made to the camera. One for the sub stream, one for the rest
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> for each camera and configure separate inputs for the main and sub streams using the local restream URLs.
|
||||
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and add separate inputs for the main and sub streams. Set each input's source to **Restream (go2rtc)** and pick the matching stream from the dropdown — Frigate uses the local restream URL and the `preset-rtsp-restream` input args for that input automatically.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@ -111,7 +111,6 @@ Navigate to <NavPath path="Settings > Global configuration > Snapshots" />.
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| **Snapshot retention > Default retention** | Number of days to retain snapshots (default: 10) |
|
||||
| **Snapshot retention > Retention mode** | Retention mode: `all`, `motion`, or `active_objects` |
|
||||
| **Snapshot retention > Object retention > Person** | Per-object overrides for retention days (e.g., keep `person` snapshots for 15 days) |
|
||||
|
||||
</TabItem>
|
||||
@ -122,7 +121,6 @@ snapshots:
|
||||
enabled: True
|
||||
retain:
|
||||
default: 10
|
||||
mode: motion
|
||||
objects:
|
||||
person: 15
|
||||
```
|
||||
|
||||
@ -348,7 +348,7 @@ In order to review activity in the Frigate UI, recordings need to be enabled.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. If you have separate streams for detect and record, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />, select your camera, and add a second input with the `record` role pointing to your high-resolution stream
|
||||
1. If you have separate streams for detect and record, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />, select your camera, and add a second input with the `record` role pointing to your high-resolution stream
|
||||
2. Navigate to <NavPath path="Settings > Global configuration > Recording" /> (or <NavPath path="Settings > Camera configuration > Recording" /> for a specific camera) and set **Enable recording** to on
|
||||
|
||||
</TabItem>
|
||||
|
||||
@ -10,13 +10,14 @@ A reverse proxy is typically needed if you want to set up Frigate on a custom UR
|
||||
Before setting up a reverse proxy, check if any of the built-in functionality in Frigate suits your needs:
|
||||
|Topic|Docs|
|
||||
|-|-|
|
||||
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|
||||
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|
||||
|Authentication|Please see the [authentication](../configuration/authentication.md) documentation|
|
||||
|IPv6|[Enabling IPv6](../configuration/advanced/system.md#enabling-ipv6)
|
||||
|
||||
**Note about TLS**
|
||||
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
|
||||
**Note about TLS**
|
||||
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
|
||||
To disable TLS, set the following in your Frigate configuration:
|
||||
|
||||
```yml
|
||||
tls:
|
||||
enabled: false
|
||||
@ -24,18 +25,26 @@ tls:
|
||||
|
||||
:::warning
|
||||
A reverse proxy can be used to secure access to an internal web server, but the user will be entirely reliant on the steps they have taken. You must ensure you are following security best practices.
|
||||
This page does not attempt to outline the specific steps needed to secure your internal website.
|
||||
This page does not attempt to outline the specific steps needed to secure your internal website.
|
||||
Please use your own knowledge to assess and vet the reverse proxy software before you install anything on your system.
|
||||
:::
|
||||
|
||||
## WebSocket support
|
||||
|
||||
Frigate relies on WebSockets for real-time communication between the browser and the backend. Features such as camera controls (enabling/disabling a camera, audio, detect, recordings, and other toggles), live stream playback, and other live-updating parts of the UI will not function correctly if WebSocket connections are not proxied.
|
||||
|
||||
Your reverse proxy must be configured to forward the `Upgrade` and `Connection` headers so that WebSocket connections can be established. Each proxy example below already includes the directives needed to do this, but if you are adapting your own configuration, ensure these headers are passed through.
|
||||
|
||||
Note that some proxies disable WebSocket support by default — for example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled.
|
||||
|
||||
## Proxies
|
||||
|
||||
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
|
||||
|
||||
* [Apache2](#apache2-reverse-proxy)
|
||||
* [Nginx](#nginx-reverse-proxy)
|
||||
* [Traefik](#traefik-reverse-proxy)
|
||||
* [Caddy](#caddy-reverse-proxy)
|
||||
- [Apache2](#apache2-reverse-proxy)
|
||||
- [Nginx](#nginx-reverse-proxy)
|
||||
- [Traefik](#traefik-reverse-proxy)
|
||||
- [Caddy](#caddy-reverse-proxy)
|
||||
|
||||
## Apache2 Reverse Proxy
|
||||
|
||||
@ -159,7 +168,7 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr
|
||||
|
||||
## Traefik Reverse Proxy
|
||||
|
||||
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
|
||||
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
|
||||
Before using the example below, you must first set up Traefik with the [Docker provider](https://doc.traefik.io/traefik/providers/docker/)
|
||||
|
||||
```yml
|
||||
@ -203,7 +212,7 @@ This example shows Frigate running under a subdomain with logging and a tls cert
|
||||
}
|
||||
|
||||
frigate.YOUR_DOMAIN.TLD {
|
||||
reverse_proxy http://localhost:8971
|
||||
reverse_proxy http://localhost:8971
|
||||
import tls
|
||||
import logging frigate.YOUR_DOMAIN.TLD
|
||||
}
|
||||
|
||||
429
docs/package-lock.json
generated
429
docs/package-lock.json
generated
@ -11,7 +11,7 @@
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/plugin-content-docs": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.10.1",
|
||||
"@inkeep/docusaurus": "^2.0.16",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@ -4006,16 +4006,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.2.tgz",
|
||||
"integrity": "sha512-5vhShRDq/ntLzdInsQkTdoKWSzw8d1jB17sNPYhA/KvYYFXfuVEGHLM6nrf8MFbV8TruAHDG21Fn3W4lO8GaDw==",
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.10.1.tgz",
|
||||
"integrity": "sha512-2gxpmln8Pc4EN1oWzshQEx2HTs67jk14v7MmgqGs8ZU7Nm8oihg+fTouof2u4vN8DtB3Fln4cDJu4UprSX1S3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/module-type-aliases": "3.9.2",
|
||||
"@docusaurus/theme-common": "3.9.2",
|
||||
"@docusaurus/types": "3.9.2",
|
||||
"@docusaurus/utils-validation": "3.9.2",
|
||||
"@docusaurus/core": "3.10.1",
|
||||
"@docusaurus/module-type-aliases": "3.10.1",
|
||||
"@docusaurus/theme-common": "3.10.1",
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"@docusaurus/utils-validation": "3.10.1",
|
||||
"mermaid": ">=11.6.0",
|
||||
"tslib": "^2.6.0"
|
||||
},
|
||||
@ -4033,6 +4033,382 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/babel": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.1.tgz",
|
||||
"integrity": "sha512-DZzFO1K3v/GoEt1fx1DiYHF4en+PuhtQf1AkQJa5zu3CoeKSpr5cpQRUlz3jr0m44wyzmSXu9bVpfir+N4+8bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.9",
|
||||
"@babel/generator": "^7.25.9",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-runtime": "^7.25.9",
|
||||
"@babel/preset-env": "^7.25.9",
|
||||
"@babel/preset-react": "^7.25.9",
|
||||
"@babel/preset-typescript": "^7.25.9",
|
||||
"@babel/runtime": "^7.25.9",
|
||||
"@babel/traverse": "^7.25.9",
|
||||
"@docusaurus/logger": "3.10.1",
|
||||
"@docusaurus/utils": "3.10.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"tslib": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/bundler": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.1.tgz",
|
||||
"integrity": "sha512-HIqQPvbqnnQRe4NsBd1774KRarjXqS6wHsWELtyuSs1gCfvixJO2jUGH/OEBtr1Gvzpw+ze5CjGMvSJ8UE1KUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.9",
|
||||
"@docusaurus/babel": "3.10.1",
|
||||
"@docusaurus/cssnano-preset": "3.10.1",
|
||||
"@docusaurus/logger": "3.10.1",
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"@docusaurus/utils": "3.10.1",
|
||||
"babel-loader": "^9.2.1",
|
||||
"clean-css": "^5.3.3",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.11.0",
|
||||
"css-minimizer-webpack-plugin": "^5.0.1",
|
||||
"cssnano": "^6.1.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-minifier-terser": "^7.2.0",
|
||||
"mini-css-extract-plugin": "^2.9.2",
|
||||
"null-loader": "^4.0.1",
|
||||
"postcss": "^8.5.4",
|
||||
"postcss-loader": "^7.3.4",
|
||||
"postcss-preset-env": "^10.2.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"tslib": "^2.6.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.95.0",
|
||||
"webpackbar": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@docusaurus/faster": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@docusaurus/faster": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/core": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.1.tgz",
|
||||
"integrity": "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/babel": "3.10.1",
|
||||
"@docusaurus/bundler": "3.10.1",
|
||||
"@docusaurus/logger": "3.10.1",
|
||||
"@docusaurus/mdx-loader": "3.10.1",
|
||||
"@docusaurus/utils": "3.10.1",
|
||||
"@docusaurus/utils-common": "3.10.1",
|
||||
"@docusaurus/utils-validation": "3.10.1",
|
||||
"boxen": "^6.2.1",
|
||||
"chalk": "^4.1.2",
|
||||
"chokidar": "^3.5.3",
|
||||
"cli-table3": "^0.6.3",
|
||||
"combine-promises": "^1.1.0",
|
||||
"commander": "^5.1.0",
|
||||
"core-js": "^3.31.1",
|
||||
"detect-port": "^1.5.1",
|
||||
"escape-html": "^1.0.3",
|
||||
"eta": "^2.2.0",
|
||||
"eval": "^0.1.8",
|
||||
"execa": "^5.1.1",
|
||||
"fs-extra": "^11.1.1",
|
||||
"html-tags": "^3.3.1",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"leven": "^3.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"open": "^8.4.0",
|
||||
"p-map": "^4.0.0",
|
||||
"prompts": "^2.4.2",
|
||||
"react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0",
|
||||
"react-loadable": "npm:@docusaurus/react-loadable@6.0.0",
|
||||
"react-loadable-ssr-addon-v5-slorber": "^1.0.3",
|
||||
"react-router": "^5.3.4",
|
||||
"react-router-config": "^5.1.1",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"semver": "^7.5.4",
|
||||
"serve-handler": "^6.1.7",
|
||||
"tinypool": "^1.0.2",
|
||||
"tslib": "^2.6.0",
|
||||
"update-notifier": "^6.0.2",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
"webpack-merge": "^6.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"docusaurus": "bin/docusaurus.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@docusaurus/faster": "*",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@docusaurus/faster": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/cssnano-preset": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.1.tgz",
|
||||
"integrity": "sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssnano-preset-advanced": "^6.1.2",
|
||||
"postcss": "^8.5.4",
|
||||
"postcss-sort-media-queries": "^5.2.0",
|
||||
"tslib": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/logger": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.1.tgz",
|
||||
"integrity": "sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"tslib": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/mdx-loader": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.1.tgz",
|
||||
"integrity": "sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/logger": "3.10.1",
|
||||
"@docusaurus/utils": "3.10.1",
|
||||
"@docusaurus/utils-validation": "3.10.1",
|
||||
"@mdx-js/mdx": "^3.0.0",
|
||||
"@slorber/remark-comment": "^1.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"estree-util-value-to-estree": "^3.0.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"image-size": "^2.0.2",
|
||||
"mdast-util-mdx": "^3.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-directive": "^3.0.0",
|
||||
"remark-emoji": "^4.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"stringify-object": "^3.3.0",
|
||||
"tslib": "^2.6.0",
|
||||
"unified": "^11.0.3",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"vfile": "^6.0.1",
|
||||
"webpack": "^5.88.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/module-type-aliases": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.1.tgz",
|
||||
"integrity": "sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*",
|
||||
"@types/react-router-config": "*",
|
||||
"@types/react-router-dom": "*",
|
||||
"react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0",
|
||||
"react-loadable": "npm:@docusaurus/react-loadable@6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/theme-common": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.1.tgz",
|
||||
"integrity": "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/mdx-loader": "3.10.1",
|
||||
"@docusaurus/module-type-aliases": "3.10.1",
|
||||
"@docusaurus/utils": "3.10.1",
|
||||
"@docusaurus/utils-common": "3.10.1",
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*",
|
||||
"@types/react-router-config": "*",
|
||||
"clsx": "^2.0.0",
|
||||
"parse-numeric-range": "^1.3.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"tslib": "^2.6.0",
|
||||
"utility-types": "^3.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@docusaurus/plugin-content-docs": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/types": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.1.tgz",
|
||||
"integrity": "sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/mdx": "^3.0.0",
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/mdast": "^4.0.2",
|
||||
"@types/react": "*",
|
||||
"commander": "^5.1.0",
|
||||
"joi": "^17.9.2",
|
||||
"react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0",
|
||||
"utility-types": "^3.10.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-merge": "^5.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/types/node_modules/webpack-merge": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz",
|
||||
"integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clone-deep": "^4.0.1",
|
||||
"flat": "^5.0.2",
|
||||
"wildcard": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/utils": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.1.tgz",
|
||||
"integrity": "sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/logger": "3.10.1",
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"@docusaurus/utils-common": "3.10.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"execa": "^5.1.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"github-slugger": "^1.5.0",
|
||||
"globby": "^11.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jiti": "^1.20.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"micromatch": "^4.0.5",
|
||||
"p-queue": "^6.6.2",
|
||||
"prompts": "^2.4.2",
|
||||
"resolve-pathname": "^3.0.0",
|
||||
"tslib": "^2.6.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"utility-types": "^3.10.0",
|
||||
"webpack": "^5.88.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/utils-common": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.1.tgz",
|
||||
"integrity": "sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"tslib": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/@docusaurus/utils-validation": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.1.tgz",
|
||||
"integrity": "sha512-cRv1X69jwaWv47waglllgZVWzeBFLhl53XT/XED/83BerVBTC5FTP8WTcVl8Z6sZOegDSwitu/wpCSPCDOT6lg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docusaurus/logger": "3.10.1",
|
||||
"@docusaurus/utils": "3.10.1",
|
||||
"@docusaurus/utils-common": "3.10.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"joi": "^17.9.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"tslib": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-mermaid/node_modules/webpackbar": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-7.0.0.tgz",
|
||||
"integrity": "sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansis": "^3.2.0",
|
||||
"consola": "^3.2.3",
|
||||
"pretty-time": "^1.1.0",
|
||||
"std-env": "^3.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rspack/core": "*",
|
||||
"webpack": "3 || 4 || 5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rspack/core": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@docusaurus/theme-search-algolia": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz",
|
||||
@ -6475,6 +6851,15 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansis": {
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz",
|
||||
"integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@ -18818,9 +19203,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-loadable-ssr-addon-v5-slorber": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz",
|
||||
"integrity": "sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.3.tgz",
|
||||
"integrity": "sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.10.3"
|
||||
@ -20601,24 +20986,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler": {
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz",
|
||||
"integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==",
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz",
|
||||
"integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.0.0",
|
||||
"content-disposition": "0.5.2",
|
||||
"mime-types": "2.1.18",
|
||||
"minimatch": "3.1.2",
|
||||
"minimatch": "3.1.5",
|
||||
"path-is-inside": "1.0.2",
|
||||
"path-to-regexp": "3.3.0",
|
||||
"range-parser": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@ -20647,9 +21032,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/plugin-content-docs": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.10.1",
|
||||
"@inkeep/docusaurus": "^2.0.16",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@ -100,8 +100,8 @@ class CameraConfig(FrigateBaseModel):
|
||||
description="Settings for face detection and recognition for this camera.",
|
||||
)
|
||||
ffmpeg: CameraFfmpegConfig = Field(
|
||||
title="FFmpeg",
|
||||
description="FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
|
||||
title="Streams (FFmpeg)",
|
||||
description="Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.",
|
||||
)
|
||||
live: CameraLiveConfig = Field(
|
||||
default_factory=CameraLiveConfig,
|
||||
|
||||
@ -3,7 +3,6 @@ from typing import Optional
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from .record import RetainModeEnum
|
||||
|
||||
__all__ = ["SnapshotsConfig", "RetainConfig"]
|
||||
|
||||
@ -14,11 +13,6 @@ class RetainConfig(FrigateBaseModel):
|
||||
title="Default retention",
|
||||
description="Default number of days to retain snapshots.",
|
||||
)
|
||||
mode: RetainModeEnum = Field(
|
||||
default=RetainModeEnum.motion,
|
||||
title="Retention mode",
|
||||
description="Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).",
|
||||
)
|
||||
objects: dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
title="Object retention",
|
||||
|
||||
1
web/e2e/fixtures/mock-data/config-schema.json
Normal file
1
web/e2e/fixtures/mock-data/config-schema.json
Normal file
File diff suppressed because one or more lines are too long
@ -111,6 +111,18 @@ def generate_config():
|
||||
return snapshot
|
||||
|
||||
|
||||
def generate_config_schema():
|
||||
"""Generate the JSON Schema for FrigateConfig from the backend model.
|
||||
|
||||
This is what the app fetches from /api/config/schema.json to drive the
|
||||
RJSF-based config form. Generating it here keeps the e2e fixture in sync
|
||||
with the backend whenever config models change.
|
||||
"""
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
return FrigateConfig.model_json_schema()
|
||||
|
||||
|
||||
def generate_reviews():
|
||||
"""Generate ReviewSegmentResponse[] validated against Pydantic + Peewee."""
|
||||
from frigate.api.defs.response.review_response import ReviewSegmentResponse
|
||||
@ -411,6 +423,7 @@ def main():
|
||||
print()
|
||||
|
||||
write_json("config-snapshot.json", generate_config())
|
||||
write_json("config-schema.json", generate_config_schema())
|
||||
write_json("reviews.json", generate_reviews())
|
||||
write_json("events.json", generate_events())
|
||||
write_json("exports.json", generate_exports())
|
||||
|
||||
203
web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts
Normal file
203
web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Camera ffmpeg streams settings tests -- MEDIUM tier.
|
||||
*
|
||||
* Covers the input-path source toggle: each ffmpeg input can either point at a
|
||||
* go2rtc restream (picked from a dropdown, which writes the rtsp://127.0.0.1:8554
|
||||
* path plus the preset-rtsp-restream input_args) or use a manually typed path.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const GO2RTC_STREAMS = {
|
||||
dome_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"],
|
||||
dome_sub: ["rtsp://user:pass@192.168.0.20:554/Stream2"],
|
||||
};
|
||||
|
||||
type CameraInput = {
|
||||
path: string;
|
||||
roles: string[];
|
||||
input_args?: string;
|
||||
};
|
||||
|
||||
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
|
||||
const config = configFactory({
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
front_door: {
|
||||
ffmpeg: { inputs: frontDoorInputs },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let lastSavedConfig: unknown = null;
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: config });
|
||||
}
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
lastSavedConfig = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||
});
|
||||
await page.route("**/api/ffmpeg/presets", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
hwaccel_args: [],
|
||||
input_args: ["preset-rtsp-restream", "preset-rtsp-generic"],
|
||||
output_args: { record: [], detect: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return { capturedConfig: () => lastSavedConfig };
|
||||
}
|
||||
|
||||
const RESTREAM_RADIO = "Restream (go2rtc)";
|
||||
const MANUAL_RADIO = "Manual input path";
|
||||
|
||||
test.describe("camera ffmpeg input source toggle @medium", () => {
|
||||
test("manual input defaults to the manual text field", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ path: "rtsp://10.0.0.1:554/video", roles: ["detect"] },
|
||||
]);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("textbox", { name: "Input path" }),
|
||||
).toHaveValue("rtsp://10.0.0.1:554/video");
|
||||
});
|
||||
|
||||
test("an existing restream path auto-detects into restream mode", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{
|
||||
path: "rtsp://127.0.0.1:8554/dome_main",
|
||||
roles: ["detect"],
|
||||
input_args: "preset-rtsp-restream",
|
||||
},
|
||||
]);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }),
|
||||
).toBeChecked();
|
||||
// The dropdown is preselected to the matching go2rtc stream.
|
||||
await expect(
|
||||
frigateApp.page.getByRole("combobox", { name: /go2rtc stream/i }),
|
||||
).toContainText("dome_main");
|
||||
});
|
||||
|
||||
test("selecting a restream writes the path and preset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRoutes(frigateApp.page, [
|
||||
{ path: "rtsp://10.0.0.1:554/video", roles: ["detect"] },
|
||||
]);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
await frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }).click();
|
||||
await frigateApp.page
|
||||
.getByRole("combobox", { name: /go2rtc stream/i })
|
||||
.click();
|
||||
|
||||
// The dropdown is searchable: typing narrows the list to matches only,
|
||||
// with no option to enter a custom stream name.
|
||||
await frigateApp.page.getByPlaceholder("Search streams...").fill("sub");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("option", { name: "dome_main" }),
|
||||
).toBeHidden();
|
||||
await frigateApp.page.getByRole("option", { name: "dome_sub" }).click();
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.toMatchObject({
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
ffmpeg: {
|
||||
inputs: [
|
||||
{
|
||||
path: "rtsp://127.0.0.1:8554/dome_sub",
|
||||
input_args: "preset-rtsp-restream",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("switching a restream back to manual reverts the preset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRoutes(frigateApp.page, [
|
||||
{
|
||||
path: "rtsp://127.0.0.1:8554/dome_main",
|
||||
roles: ["detect"],
|
||||
input_args: "preset-rtsp-restream",
|
||||
},
|
||||
]);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
await frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }).click();
|
||||
|
||||
// The restream path stays editable in the manual text field.
|
||||
await expect(
|
||||
frigateApp.page.getByRole("textbox", { name: "Input path" }),
|
||||
).toHaveValue("rtsp://127.0.0.1:8554/dome_main");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.not.toBeNull();
|
||||
|
||||
const payload = capture.capturedConfig() as {
|
||||
config_data?: {
|
||||
cameras?: {
|
||||
front_door?: {
|
||||
ffmpeg?: { inputs?: Array<{ input_args?: unknown }> };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
const input =
|
||||
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
|
||||
expect(input?.input_args).not.toBe("preset-rtsp-restream");
|
||||
});
|
||||
});
|
||||
@ -152,8 +152,8 @@
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"label": "FFmpeg",
|
||||
"description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
|
||||
"label": "Streams (FFmpeg)",
|
||||
"description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.",
|
||||
"path": {
|
||||
"label": "FFmpeg path",
|
||||
"description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")."
|
||||
@ -666,10 +666,6 @@
|
||||
"label": "Default retention",
|
||||
"description": "Default number of days to retain snapshots."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Retention mode",
|
||||
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Object retention",
|
||||
"description": "Per-object overrides for snapshot retention days."
|
||||
|
||||
@ -1176,10 +1176,6 @@
|
||||
"label": "Default retention",
|
||||
"description": "Default number of days to retain snapshots."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Retention mode",
|
||||
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Object retention",
|
||||
"description": "Per-object overrides for snapshot retention days."
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
"integrationObjectClassification": "Object classification",
|
||||
"integrationAudioTranscription": "Audio transcription",
|
||||
"cameraDetect": "Object detection",
|
||||
"cameraFfmpeg": "FFmpeg",
|
||||
"cameraFfmpeg": "Streams (FFmpeg)",
|
||||
"cameraRecording": "Recording",
|
||||
"cameraSnapshots": "Snapshots",
|
||||
"cameraMotion": "Motion detection",
|
||||
@ -1553,7 +1553,17 @@
|
||||
}
|
||||
},
|
||||
"cameraInputs": {
|
||||
"itemTitle": "Stream {{index}}"
|
||||
"itemTitle": "Stream {{index}}",
|
||||
"sourceMode": {
|
||||
"restream": "Restream (go2rtc)",
|
||||
"manual": "Manual input path",
|
||||
"go2rtcStreamLabel": "go2rtc stream",
|
||||
"go2rtcStreamPlaceholder": "Select a go2rtc stream",
|
||||
"noGo2rtcStreams": "No go2rtc streams configured",
|
||||
"go2rtcStreamSearch": "Search streams...",
|
||||
"availableStreams": "Available streams",
|
||||
"noMatchingStreams": "No matching streams"
|
||||
}
|
||||
},
|
||||
"restartRequiredField": "Restart required",
|
||||
"restartRequiredFooter": "Configuration changed - Restart required",
|
||||
|
||||
@ -44,7 +44,14 @@ const record: SectionConfigOverrides = {
|
||||
hiddenFields: ["enabled_in_config", "sync_recordings"],
|
||||
advancedFields: ["expire_interval", "preview", "export"],
|
||||
uiSchema: {
|
||||
continuous: {
|
||||
"ui:options": { defaultOpen: true, disableCollapsible: true },
|
||||
},
|
||||
motion: {
|
||||
"ui:options": { defaultOpen: true, disableCollapsible: true },
|
||||
},
|
||||
export: {
|
||||
"ui:options": { defaultOpen: true, disableCollapsible: true },
|
||||
hwaccel_args: {
|
||||
"ui:widget": "FfmpegArgsWidget",
|
||||
"ui:options": {
|
||||
@ -59,9 +66,12 @@ const record: SectionConfigOverrides = {
|
||||
"detections.retain.mode": {
|
||||
"ui:options": { enumI18nPrefix: "retainMode" },
|
||||
},
|
||||
"preview.quality": {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "previewQuality",
|
||||
preview: {
|
||||
"ui:options": { defaultOpen: true, disableCollapsible: true },
|
||||
quality: {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "previewQuality",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -21,13 +21,14 @@ const snapshots: SectionConfigOverrides = {
|
||||
"crop",
|
||||
"quality",
|
||||
"timestamp",
|
||||
"required_zones",
|
||||
"retain",
|
||||
],
|
||||
fieldGroups: {
|
||||
display: ["bounding_box", "crop", "quality", "timestamp"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality", "retain"],
|
||||
advancedFields: ["height", "quality"],
|
||||
uiSchema: {
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
@ -35,11 +36,6 @@ const snapshots: SectionConfigOverrides = {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
"retain.mode": {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "retainMode",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
|
||||
@ -29,11 +29,19 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { StreamSourceSelector } from "./StreamSourceSelector";
|
||||
import {
|
||||
buildRestreamPath,
|
||||
parseRestreamStreamName,
|
||||
RESTREAM_PRESET,
|
||||
type StreamSourceMode,
|
||||
} from "./streamSource";
|
||||
|
||||
type FfmpegInput = {
|
||||
path?: string;
|
||||
roles?: string[];
|
||||
hwaccel_args?: unknown;
|
||||
input_args?: unknown;
|
||||
};
|
||||
|
||||
const asInputList = (formData: unknown): FfmpegInput[] => {
|
||||
@ -137,7 +145,30 @@ export function CameraInputsField(props: FieldProps) {
|
||||
);
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
|
||||
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 [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
|
||||
const [sourceModeByIndex, setSourceModeByIndex] = useState<
|
||||
Record<number, StreamSourceMode>
|
||||
>({});
|
||||
|
||||
// Detect whether an existing input path points at a known go2rtc restream so
|
||||
// the source toggle can default to the right mode for existing configs.
|
||||
const detectMode = useCallback(
|
||||
(path: string | undefined): StreamSourceMode => {
|
||||
const streamName = parseRestreamStreamName(path);
|
||||
return streamName && go2rtcStreamNames.includes(streamName)
|
||||
? "restream"
|
||||
: "manual";
|
||||
},
|
||||
[go2rtcStreamNames],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenByIndex((previous) => {
|
||||
@ -171,6 +202,55 @@ export function CameraInputsField(props: FieldProps) {
|
||||
[fieldPathId.path, inputs, onChange],
|
||||
);
|
||||
|
||||
// Update several fields of one input in a single change so that path and
|
||||
// input_args never race on a stale snapshot of inputs.
|
||||
const handleFieldValuesChange = useCallback(
|
||||
(index: number, partial: Record<string, unknown>) => {
|
||||
const nextInputs = cloneDeep(inputs);
|
||||
const item =
|
||||
(nextInputs[index] as Record<string, unknown> | undefined) ??
|
||||
({} as Record<string, unknown>);
|
||||
|
||||
Object.assign(item, partial);
|
||||
nextInputs[index] = item;
|
||||
|
||||
onChange(normalizeNonDetectHwaccel(nextInputs), fieldPathId.path);
|
||||
},
|
||||
[fieldPathId.path, inputs, onChange],
|
||||
);
|
||||
|
||||
const handleSourceModeChange = useCallback(
|
||||
(index: number, nextMode: StreamSourceMode) => {
|
||||
const input = inputs[index];
|
||||
const currentPath =
|
||||
typeof input?.path === "string" ? input.path : undefined;
|
||||
|
||||
if (nextMode === "manual") {
|
||||
// Only revert the preset we set ourselves; never clobber custom args.
|
||||
if (input?.input_args === RESTREAM_PRESET) {
|
||||
handleFieldValuesChange(index, { input_args: undefined });
|
||||
}
|
||||
} else if (!parseRestreamStreamName(currentPath)) {
|
||||
// Entering restream with a non-restream path: clear it so the dropdown
|
||||
// shows its placeholder until a stream is chosen.
|
||||
handleFieldValuesChange(index, { path: undefined });
|
||||
}
|
||||
|
||||
setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode }));
|
||||
},
|
||||
[inputs, handleFieldValuesChange],
|
||||
);
|
||||
|
||||
const handleSelectRestreamStream = useCallback(
|
||||
(index: number, streamName: string) => {
|
||||
handleFieldValuesChange(index, {
|
||||
path: buildRestreamPath(streamName),
|
||||
input_args: RESTREAM_PRESET,
|
||||
});
|
||||
},
|
||||
[handleFieldValuesChange],
|
||||
);
|
||||
|
||||
const handleAddInput = useCallback(() => {
|
||||
const base = itemSchema
|
||||
? (applySchemaDefaults(itemSchema) as FfmpegInput)
|
||||
@ -186,8 +266,9 @@ export function CameraInputsField(props: FieldProps) {
|
||||
(_, currentIndex) => currentIndex !== index,
|
||||
);
|
||||
onChange(nextInputs, fieldPathId.path);
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
|
||||
const reindex = <T,>(previous: Record<number, T>): Record<number, T> => {
|
||||
const next: Record<number, T> = {};
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
const current = Number(key);
|
||||
if (Number.isNaN(current) || current === index) {
|
||||
@ -197,7 +278,10 @@ export function CameraInputsField(props: FieldProps) {
|
||||
next[current > index ? current - 1 : current] = value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
setOpenByIndex(reindex);
|
||||
setSourceModeByIndex(reindex);
|
||||
},
|
||||
[fieldPathId.path, inputs, onChange],
|
||||
);
|
||||
@ -354,16 +438,32 @@ export function CameraInputsField(props: FieldProps) {
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
<div className="w-full">
|
||||
{renderField(index, "path", {
|
||||
extraUiSchema: {
|
||||
"ui:widget": "CameraPathWidget",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
splitLayout: false,
|
||||
<StreamSourceSelector
|
||||
idPrefix={`${baseId}-${index}`}
|
||||
mode={sourceModeByIndex[index] ?? detectMode(input.path)}
|
||||
onModeChange={(nextMode) =>
|
||||
handleSourceModeChange(index, nextMode)
|
||||
}
|
||||
streamNames={go2rtcStreamNames}
|
||||
selectedStreamName={
|
||||
parseRestreamStreamName(input.path) ?? ""
|
||||
}
|
||||
onSelectStream={(streamName) =>
|
||||
handleSelectRestreamStream(index, streamName)
|
||||
}
|
||||
manualField={renderField(index, "path", {
|
||||
extraUiSchema: {
|
||||
"ui:widget": "CameraPathWidget",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
splitLayout: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
showSchemaDescription: true,
|
||||
})}
|
||||
showSchemaDescription: true,
|
||||
})}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">{renderField(index, "roles")}</div>
|
||||
|
||||
@ -0,0 +1,217 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import type { StreamSourceMode } from "./streamSource";
|
||||
|
||||
type Go2rtcStreamComboboxProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
disabled?: boolean;
|
||||
onSelect: (streamName: string) => void;
|
||||
};
|
||||
|
||||
// Searchable dropdown of existing go2rtc streams
|
||||
function Go2rtcStreamCombobox({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: Go2rtcStreamComboboxProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const commit = (next: string) => {
|
||||
onSelect(next);
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
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}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal sm:max-w-xs",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{value ||
|
||||
t("configForm.cameraInputs.sourceMode.go2rtcStreamPlaceholder")}
|
||||
</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={t(
|
||||
"configForm.cameraInputs.sourceMode.go2rtcStreamSearch",
|
||||
)}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("configForm.cameraInputs.sourceMode.noMatchingStreams")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup
|
||||
heading={t("configForm.cameraInputs.sourceMode.availableStreams")}
|
||||
>
|
||||
{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>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type StreamSourceSelectorProps = {
|
||||
idPrefix: string;
|
||||
mode: StreamSourceMode;
|
||||
onModeChange: (mode: StreamSourceMode) => void;
|
||||
streamNames: string[];
|
||||
selectedStreamName: string;
|
||||
onSelectStream: (streamName: string) => void;
|
||||
manualField: ReactNode;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export function StreamSourceSelector({
|
||||
idPrefix,
|
||||
mode,
|
||||
onModeChange,
|
||||
streamNames,
|
||||
selectedStreamName,
|
||||
onSelectStream,
|
||||
manualField,
|
||||
disabled,
|
||||
readonly,
|
||||
}: StreamSourceSelectorProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const restreamId = `${idPrefix}-source-restream`;
|
||||
const manualId = `${idPrefix}-source-manual`;
|
||||
const selectId = `${idPrefix}-restream-select`;
|
||||
|
||||
const hasStreams = streamNames.length > 0;
|
||||
const isDisabled = disabled || readonly;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => onModeChange(value as StreamSourceMode)}
|
||||
className="flex flex-col gap-2 sm:flex-row sm:gap-6"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="restream"
|
||||
id={restreamId}
|
||||
className={
|
||||
mode === "restream"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor={restreamId} className="cursor-pointer text-sm">
|
||||
{t("configForm.cameraInputs.sourceMode.restream")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="manual"
|
||||
id={manualId}
|
||||
className={
|
||||
mode === "manual"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor={manualId} className="cursor-pointer text-sm">
|
||||
{t("configForm.cameraInputs.sourceMode.manual")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{mode === "restream" ? (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label htmlFor={selectId} className="block">
|
||||
{t("configForm.cameraInputs.sourceMode.go2rtcStreamLabel")}
|
||||
</Label>
|
||||
{hasStreams ? (
|
||||
<Go2rtcStreamCombobox
|
||||
id={selectId}
|
||||
value={selectedStreamName}
|
||||
options={streamNames}
|
||||
disabled={isDisabled}
|
||||
onSelect={onSelectStream}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"rounded-md border border-dashed p-3 text-sm text-muted-foreground sm:max-w-xs",
|
||||
)}
|
||||
>
|
||||
{t("configForm.cameraInputs.sourceMode.noGo2rtcStreams")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
manualField
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StreamSourceSelector;
|
||||
33
web/src/components/config-form/theme/fields/streamSource.ts
Normal file
33
web/src/components/config-form/theme/fields/streamSource.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export type StreamSourceMode = "restream" | "manual";
|
||||
|
||||
// The literal go2rtc restream prefix matches what the camera wizard inlines
|
||||
// when it builds a restreamed input path. Only this exact host:port is treated
|
||||
// as a restream so manually typed URLs (including localhost) stay manual.
|
||||
export const RESTREAM_PREFIX = "rtsp://127.0.0.1:8554/";
|
||||
export const RESTREAM_PRESET = "preset-rtsp-restream";
|
||||
|
||||
/** Build the restream input path for a given go2rtc stream name. */
|
||||
export function buildRestreamPath(streamName: string): string {
|
||||
return `${RESTREAM_PREFIX}${streamName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the go2rtc stream name from a restream input path.
|
||||
*
|
||||
* Returns the stream name when the path is a well-formed restream URL with no
|
||||
* extra path segments or query, otherwise undefined.
|
||||
*/
|
||||
export function parseRestreamStreamName(
|
||||
path: string | undefined,
|
||||
): string | undefined {
|
||||
if (typeof path !== "string" || !path.startsWith(RESTREAM_PREFIX)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const name = path.slice(RESTREAM_PREFIX.length);
|
||||
if (name.length === 0 || /[/?#]/.test(name)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
@ -156,7 +156,8 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
};
|
||||
|
||||
const hasModifiedDescendants = checkSubtreeModified(fieldPath);
|
||||
const [isOpen, setIsOpen] = useState(hasModifiedDescendants);
|
||||
const defaultOpen = uiSchema?.["ui:options"]?.defaultOpen === true;
|
||||
const [isOpen, setIsOpen] = useState(hasModifiedDescendants || defaultOpen);
|
||||
const resetKey = `${formContext?.level ?? "global"}::${
|
||||
formContext?.cameraName ?? "global"
|
||||
}`;
|
||||
@ -192,6 +193,8 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
|
||||
const disableNestedCard =
|
||||
uiSchema?.["ui:options"]?.disableNestedCard === true;
|
||||
const disableCollapsible =
|
||||
uiSchema?.["ui:options"]?.disableCollapsible === true;
|
||||
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
|
||||
@ -228,10 +231,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
useEffect(() => {
|
||||
if (lastResetKeyRef.current !== resetKey) {
|
||||
lastResetKeyRef.current = resetKey;
|
||||
setIsOpen(hasModifiedDescendants);
|
||||
setIsOpen(hasModifiedDescendants || defaultOpen);
|
||||
setShowAdvanced(hasModifiedAdvanced);
|
||||
}
|
||||
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced]);
|
||||
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced, defaultOpen]);
|
||||
const { children } = props as ObjectFieldTemplateProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
@ -458,6 +461,75 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Label/description/docs header shared by the collapsible and static layouts.
|
||||
const cardHeaderContent = (
|
||||
<div className="min-w-0 pr-3">
|
||||
<CardTitle
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
hasModifiedDescendants && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
{inferredLabel}
|
||||
{objectRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
</CardTitle>
|
||||
{inferredDescription && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{inferredDescription}
|
||||
</p>
|
||||
)}
|
||||
{fieldDocsUrl && (
|
||||
<div className="mt-1 flex items-center text-xs text-primary-variant">
|
||||
<Link
|
||||
to={fieldDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Body shared by the collapsible and static layouts.
|
||||
const cardBody = hasCustomChildren ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
);
|
||||
|
||||
// Static (non-collapsible) card: keep the labeled header, always show content.
|
||||
if (disableCollapsible) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="p-4">{cardHeaderContent}</CardHeader>
|
||||
<CardContent className="space-y-6 p-4 pt-0">{cardBody}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Nested objects render as collapsible cards
|
||||
return (
|
||||
<Card className="w-full">
|
||||
@ -465,38 +537,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 pr-3">
|
||||
<CardTitle
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
hasModifiedDescendants && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
{inferredLabel}
|
||||
{objectRequiresRestart && (
|
||||
<RestartRequiredIndicator className="ml-2" />
|
||||
)}
|
||||
</CardTitle>
|
||||
{inferredDescription && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{inferredDescription}
|
||||
</p>
|
||||
)}
|
||||
{fieldDocsUrl && (
|
||||
<div className="mt-1 flex items-center text-xs text-primary-variant">
|
||||
<Link
|
||||
to={fieldDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{cardHeaderContent}
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
@ -506,31 +547,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-6 p-4 pt-0">
|
||||
{hasCustomChildren ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardContent className="space-y-6 p-4 pt-0">{cardBody}</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
|
||||
@ -34,6 +34,8 @@ import { isMobile } from "react-device-detect";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { maskCredentials } from "@/utils/credentialMask";
|
||||
import useSWR from "swr";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||
@ -660,6 +662,11 @@ export default function Settings() {
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// for unmasked go2rtc stream sources
|
||||
const { data: rawPaths } = useSWR<{
|
||||
go2rtc: { streams: Record<string, string | string[]> };
|
||||
}>(isAdmin ? "config/raw_paths" : null);
|
||||
|
||||
const visibleSettingsViews = !isAdmin
|
||||
? ALLOWED_VIEWS_FOR_VIEWER
|
||||
: allSettingsViews;
|
||||
@ -788,6 +795,40 @@ export default function Settings() {
|
||||
},
|
||||
);
|
||||
|
||||
// go2rtc streams aren't schema-backed, so build their preview items directly
|
||||
if ("go2rtc_streams" in pendingDataBySection) {
|
||||
const live =
|
||||
(pendingDataBySection["go2rtc_streams"] as Record<string, string[]>) ??
|
||||
{};
|
||||
const saved: Record<string, string[]> = {};
|
||||
for (const [name, urls] of Object.entries(
|
||||
rawPaths?.go2rtc?.streams ?? {},
|
||||
)) {
|
||||
saved[name] = Array.isArray(urls) ? urls : [urls];
|
||||
}
|
||||
|
||||
// Added or changed streams
|
||||
for (const [name, urls] of Object.entries(live)) {
|
||||
if (name in saved && isEqual(urls, saved[name])) continue;
|
||||
const masked = urls.map((url) => maskCredentials(url));
|
||||
items.push({
|
||||
scope: "global",
|
||||
fieldPath: `go2rtc.streams.${name}`,
|
||||
value: masked.length === 1 ? masked[0] : masked,
|
||||
});
|
||||
}
|
||||
|
||||
// Deleted streams (present in saved config, absent from pending)
|
||||
for (const name of Object.keys(saved)) {
|
||||
if (name in live) continue;
|
||||
items.push({
|
||||
scope: "global",
|
||||
fieldPath: `go2rtc.streams.${name}`,
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.sort((left, right) => {
|
||||
const scopeCompare = left.scope.localeCompare(right.scope);
|
||||
if (scopeCompare !== 0) return scopeCompare;
|
||||
@ -797,7 +838,13 @@ export default function Settings() {
|
||||
if (cameraCompare !== 0) return cameraCompare;
|
||||
return left.fieldPath.localeCompare(right.fieldPath);
|
||||
});
|
||||
}, [config, fullSchema, pendingDataBySection, profileFriendlyNames]);
|
||||
}, [
|
||||
config,
|
||||
fullSchema,
|
||||
pendingDataBySection,
|
||||
profileFriendlyNames,
|
||||
rawPaths,
|
||||
]);
|
||||
|
||||
// Map a pendingDataKey to SettingsType menu key for clearing section status
|
||||
const pendingKeyToMenuKey = useCallback(
|
||||
@ -869,10 +916,7 @@ export default function Settings() {
|
||||
// after `mutate("config")` resolves
|
||||
const keysToClear: string[] = [];
|
||||
|
||||
// `detectors` and `model` are owned by DetectorsAndModelSettingsView,
|
||||
// which saves them atomically (single combined PUT with a pre-clear when
|
||||
// detector keys change or the Plus/Custom tab flips). Doing the same here
|
||||
// keeps Save All consistent with the page's own Save button
|
||||
// `detectors` and `model` are owned by DetectorsAndModelSettingsView
|
||||
const hasPendingDetectors = "detectors" in pendingDataBySection;
|
||||
const hasPendingModel = "model" in pendingDataBySection;
|
||||
if (hasPendingDetectors || hasPendingModel) {
|
||||
@ -975,8 +1019,58 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
// go2rtc streams are owned by Go2RtcStreamsSettingsView
|
||||
if ("go2rtc_streams" in pendingDataBySection) {
|
||||
try {
|
||||
const liveStreams =
|
||||
(pendingDataBySection["go2rtc_streams"] as Record<
|
||||
string,
|
||||
string[]
|
||||
>) ?? {};
|
||||
const streamsPayload: Record<string, string[] | string> = {
|
||||
...liveStreams,
|
||||
};
|
||||
const deletedStreamNames = Object.keys(
|
||||
config.go2rtc?.streams ?? {},
|
||||
).filter((name) => !(name in liveStreams));
|
||||
for (const deleted of deletedStreamNames) {
|
||||
streamsPayload[deleted] = "";
|
||||
}
|
||||
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: { go2rtc: { streams: streamsPayload } },
|
||||
});
|
||||
|
||||
// Update the running go2rtc instance to match
|
||||
const go2rtcUpdates: Promise<unknown>[] = [];
|
||||
for (const [streamName, urls] of Object.entries(liveStreams)) {
|
||||
if (urls[0]) {
|
||||
go2rtcUpdates.push(
|
||||
axios.put(
|
||||
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const deleted of deletedStreamNames) {
|
||||
go2rtcUpdates.push(axios.delete(`go2rtc/streams/${deleted}`));
|
||||
}
|
||||
await Promise.allSettled(go2rtcUpdates);
|
||||
|
||||
keysToClear.push("go2rtc_streams");
|
||||
savedKeys.push("go2rtc_streams");
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Save All – error saving go2rtc streams", error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const pendingKeys = Object.keys(pendingDataBySection).filter(
|
||||
(key) => key !== "detectors" && key !== "model",
|
||||
(key) =>
|
||||
key !== "detectors" && key !== "model" && key !== "go2rtc_streams",
|
||||
);
|
||||
|
||||
for (const key of pendingKeys) {
|
||||
|
||||
@ -58,8 +58,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import SaveAllPreviewPopover, {
|
||||
type SaveAllPreviewItem,
|
||||
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
isMaskedPath,
|
||||
@ -85,18 +90,8 @@ type RawPathsResponse = {
|
||||
go2rtc: { streams: Record<string, string | string[]> };
|
||||
};
|
||||
|
||||
type Go2RtcStreamsSettingsViewProps = {
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onSectionStatusChange?: (
|
||||
sectionKey: string,
|
||||
level: "global" | "camera",
|
||||
status: {
|
||||
hasChanges: boolean;
|
||||
isOverridden: boolean;
|
||||
hasValidationErrors: boolean;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
const SECTION_KEY = "go2rtc_streams";
|
||||
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
|
||||
|
||||
const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
@ -114,7 +109,11 @@ function normalizeStreams(
|
||||
export default function Go2RtcStreamsSettingsView({
|
||||
setUnsavedChanges,
|
||||
onSectionStatusChange,
|
||||
}: Go2RtcStreamsSettingsViewProps) {
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
isSavingAll,
|
||||
onSectionSavingChange,
|
||||
}: SettingsPageProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config, mutate: updateConfig } =
|
||||
@ -122,13 +121,6 @@ export default function Go2RtcStreamsSettingsView({
|
||||
const { data: rawPaths, mutate: updateRawPaths } =
|
||||
useSWR<RawPathsResponse>("config/raw_paths");
|
||||
|
||||
const [editedStreams, setEditedStreams] = useState<Record<string, string[]>>(
|
||||
{},
|
||||
);
|
||||
const [serverStreams, setServerStreams] = useState<Record<string, string[]>>(
|
||||
{},
|
||||
);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [credentialVisibility, setCredentialVisibility] = useState<
|
||||
Record<string, boolean>
|
||||
@ -138,34 +130,51 @@ export default function Go2RtcStreamsSettingsView({
|
||||
const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false);
|
||||
const [newlyAdded, setNewlyAdded] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize from config — wait for both config and rawPaths to avoid
|
||||
// a mismatch when rawPaths arrives after config with different data
|
||||
useEffect(() => {
|
||||
if (!config || !rawPaths) return;
|
||||
const childPending = pendingDataBySection ?? EMPTY_PENDING;
|
||||
|
||||
// Always use rawPaths for go2rtc streams — the /config endpoint masks
|
||||
// credentials, so using config.go2rtc.streams would save masked values
|
||||
const normalized = normalizeStreams(rawPaths.go2rtc?.streams);
|
||||
// Saved/server state. Always read from rawPaths
|
||||
const serverStreams = useMemo<Record<string, string[]>>(
|
||||
() => normalizeStreams(rawPaths?.go2rtc?.streams),
|
||||
[rawPaths],
|
||||
);
|
||||
|
||||
setServerStreams(normalized);
|
||||
if (!initialized) {
|
||||
setEditedStreams(normalized);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [config, rawPaths, initialized]);
|
||||
// Pending edits live in the parent's store so they survive navigation; fall back to saved state
|
||||
const liveStreams = useMemo<Record<string, string[]>>(
|
||||
() =>
|
||||
(childPending[SECTION_KEY] as Record<string, string[]> | undefined) ??
|
||||
serverStreams,
|
||||
[childPending, serverStreams],
|
||||
);
|
||||
|
||||
// Persist edits to the parent store, clearing the entry when an edit returns
|
||||
// the section to its saved state so Save All and the sidebar dot reset cleanly.
|
||||
const commitStreams = useCallback(
|
||||
(next: Record<string, string[]>) => {
|
||||
if (isEqual(next, serverStreams)) {
|
||||
onPendingDataChange?.(SECTION_KEY, undefined, null);
|
||||
} else {
|
||||
onPendingDataChange?.(
|
||||
SECTION_KEY,
|
||||
undefined,
|
||||
next as ConfigSectionData,
|
||||
);
|
||||
}
|
||||
},
|
||||
[serverStreams, onPendingDataChange],
|
||||
);
|
||||
|
||||
// Track unsaved changes
|
||||
const hasChanges = useMemo(
|
||||
() => initialized && !isEqual(editedStreams, serverStreams),
|
||||
[editedStreams, serverStreams, initialized],
|
||||
() => !isEqual(liveStreams, serverStreams),
|
||||
[liveStreams, serverStreams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setUnsavedChanges(hasChanges);
|
||||
setUnsavedChanges?.(hasChanges);
|
||||
}, [hasChanges, setUnsavedChanges]);
|
||||
|
||||
const hasValidationErrors = useMemo(() => {
|
||||
const names = Object.keys(editedStreams);
|
||||
const names = Object.keys(liveStreams);
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
for (const name of names) {
|
||||
@ -173,13 +182,43 @@ export default function Go2RtcStreamsSettingsView({
|
||||
if (seenNames.has(name)) return true;
|
||||
seenNames.add(name);
|
||||
|
||||
const urls = editedStreams[name];
|
||||
const urls = liveStreams[name];
|
||||
if (!urls || urls.length === 0 || urls.every((u) => !u.trim()))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [editedStreams]);
|
||||
}, [liveStreams]);
|
||||
|
||||
// Pending changes for this section's Save All preview popover. Diff the
|
||||
// pending streams against the saved state and mask credentials for display.
|
||||
const sectionPreviewItems = useMemo<SaveAllPreviewItem[]>(() => {
|
||||
if (!hasChanges) return [];
|
||||
const items: SaveAllPreviewItem[] = [];
|
||||
|
||||
// Added or changed streams
|
||||
for (const [name, urls] of Object.entries(liveStreams)) {
|
||||
if (name in serverStreams && isEqual(urls, serverStreams[name])) continue;
|
||||
const masked = urls.map((url) => maskCredentials(url));
|
||||
items.push({
|
||||
scope: "global",
|
||||
fieldPath: `go2rtc.streams.${name}`,
|
||||
value: masked.length === 1 ? masked[0] : masked,
|
||||
});
|
||||
}
|
||||
|
||||
// Deleted streams (present in saved config, absent from pending)
|
||||
for (const name of Object.keys(serverStreams)) {
|
||||
if (name in liveStreams) continue;
|
||||
items.push({
|
||||
scope: "global",
|
||||
fieldPath: `go2rtc.streams.${name}`,
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [hasChanges, liveStreams, serverStreams]);
|
||||
|
||||
// Report status to parent for sidebar red dot
|
||||
useEffect(() => {
|
||||
@ -193,13 +232,14 @@ export default function Go2RtcStreamsSettingsView({
|
||||
// Save handler
|
||||
const saveToConfig = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
onSectionSavingChange?.(true);
|
||||
|
||||
try {
|
||||
const streamsPayload: Record<string, string[] | string> = {
|
||||
...editedStreams,
|
||||
...liveStreams,
|
||||
};
|
||||
const deletedStreamNames = Object.keys(serverStreams).filter(
|
||||
(name) => !(name in editedStreams),
|
||||
(name) => !(name in liveStreams),
|
||||
);
|
||||
for (const deleted of deletedStreamNames) {
|
||||
streamsPayload[deleted] = "";
|
||||
@ -212,7 +252,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
|
||||
// Update running go2rtc instance
|
||||
const go2rtcUpdates: Promise<unknown>[] = [];
|
||||
for (const [streamName, urls] of Object.entries(editedStreams)) {
|
||||
for (const [streamName, urls] of Object.entries(liveStreams)) {
|
||||
if (urls[0]) {
|
||||
go2rtcUpdates.push(
|
||||
axios.put(
|
||||
@ -233,9 +273,9 @@ export default function Go2RtcStreamsSettingsView({
|
||||
}),
|
||||
);
|
||||
|
||||
setServerStreams(editedStreams);
|
||||
updateConfig();
|
||||
updateRawPaths();
|
||||
await updateConfig();
|
||||
await updateRawPaths();
|
||||
onPendingDataChange?.(SECTION_KEY, undefined, null);
|
||||
} catch {
|
||||
toast.error(
|
||||
t("toast.error", {
|
||||
@ -245,74 +285,86 @@ export default function Go2RtcStreamsSettingsView({
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
onSectionSavingChange?.(false);
|
||||
}
|
||||
}, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]);
|
||||
}, [
|
||||
liveStreams,
|
||||
serverStreams,
|
||||
t,
|
||||
updateConfig,
|
||||
updateRawPaths,
|
||||
onPendingDataChange,
|
||||
onSectionSavingChange,
|
||||
]);
|
||||
|
||||
// Reset handler
|
||||
const onReset = useCallback(() => {
|
||||
setEditedStreams(serverStreams);
|
||||
onPendingDataChange?.(SECTION_KEY, undefined, null);
|
||||
setCredentialVisibility({});
|
||||
}, [serverStreams]);
|
||||
}, [onPendingDataChange]);
|
||||
|
||||
// Stream CRUD operations
|
||||
const addStream = useCallback((name: string) => {
|
||||
setEditedStreams((prev) => ({ ...prev, [name]: [""] }));
|
||||
setNewlyAdded((prev) => new Set(prev).add(name));
|
||||
setAddStreamDialogOpen(false);
|
||||
}, []);
|
||||
const addStream = useCallback(
|
||||
(name: string) => {
|
||||
commitStreams({ ...liveStreams, [name]: [""] });
|
||||
setNewlyAdded((prev) => new Set(prev).add(name));
|
||||
setAddStreamDialogOpen(false);
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
|
||||
const deleteStream = useCallback((streamName: string) => {
|
||||
setEditedStreams((prev) => {
|
||||
const { [streamName]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setDeleteDialog(null);
|
||||
}, []);
|
||||
const deleteStream = useCallback(
|
||||
(streamName: string) => {
|
||||
const { [streamName]: _removed, ...rest } = liveStreams;
|
||||
commitStreams(rest);
|
||||
setDeleteDialog(null);
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
|
||||
const renameStream = useCallback((oldName: string, newName: string) => {
|
||||
if (oldName === newName || !newName.trim()) return;
|
||||
const renameStream = useCallback(
|
||||
(oldName: string, newName: string) => {
|
||||
if (oldName === newName || !newName.trim()) return;
|
||||
if (!(oldName in liveStreams)) return;
|
||||
|
||||
setEditedStreams((prev) => {
|
||||
const urls = prev[oldName];
|
||||
if (!urls) return prev;
|
||||
|
||||
const entries = Object.entries(prev);
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, value] of entries) {
|
||||
if (key === oldName) {
|
||||
result[newName] = value;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(liveStreams)) {
|
||||
result[key === oldName ? newName : key] = value;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}, []);
|
||||
commitStreams(result);
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(streamName: string, urlIndex: number, newUrl: string) => {
|
||||
setEditedStreams((prev) => {
|
||||
const urls = [...(prev[streamName] || [])];
|
||||
urls[urlIndex] = newUrl;
|
||||
return { ...prev, [streamName]: urls };
|
||||
});
|
||||
const urls = [...(liveStreams[streamName] || [])];
|
||||
urls[urlIndex] = newUrl;
|
||||
commitStreams({ ...liveStreams, [streamName]: urls });
|
||||
},
|
||||
[],
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
|
||||
const addUrl = useCallback((streamName: string) => {
|
||||
setEditedStreams((prev) => {
|
||||
const urls = [...(prev[streamName] || []), ""];
|
||||
return { ...prev, [streamName]: urls };
|
||||
});
|
||||
}, []);
|
||||
const addUrl = useCallback(
|
||||
(streamName: string) => {
|
||||
const urls = [...(liveStreams[streamName] || []), ""];
|
||||
commitStreams({ ...liveStreams, [streamName]: urls });
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
|
||||
const removeUrl = useCallback((streamName: string, urlIndex: number) => {
|
||||
setEditedStreams((prev) => {
|
||||
const urls = (prev[streamName] || []).filter((_, i) => i !== urlIndex);
|
||||
return { ...prev, [streamName]: urls.length > 0 ? urls : [""] };
|
||||
});
|
||||
}, []);
|
||||
const removeUrl = useCallback(
|
||||
(streamName: string, urlIndex: number) => {
|
||||
const urls = (liveStreams[streamName] || []).filter(
|
||||
(_, i) => i !== urlIndex,
|
||||
);
|
||||
commitStreams({
|
||||
...liveStreams,
|
||||
[streamName]: urls.length > 0 ? urls : [""],
|
||||
});
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
|
||||
const toggleCredentialVisibility = useCallback((key: string) => {
|
||||
setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
@ -320,7 +372,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const streamEntries = Object.entries(editedStreams);
|
||||
const streamEntries = Object.entries(liveStreams);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col lg:pr-2">
|
||||
@ -391,6 +443,12 @@ export default function Go2RtcStreamsSettingsView({
|
||||
<span className="text-sm text-unsaved">
|
||||
{t("unsavedChanges")}
|
||||
</span>
|
||||
<SaveAllPreviewPopover
|
||||
items={sectionPreviewItems}
|
||||
className="h-7 w-7"
|
||||
align="start"
|
||||
side="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
|
||||
@ -398,7 +456,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
<Button
|
||||
onClick={onReset}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isSavingAll}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
{t("button.undo", { ns: "common" })}
|
||||
@ -407,7 +465,9 @@ export default function Go2RtcStreamsSettingsView({
|
||||
<Button
|
||||
onClick={saveToConfig}
|
||||
variant="select"
|
||||
disabled={!hasChanges || isLoading || hasValidationErrors}
|
||||
disabled={
|
||||
!hasChanges || isLoading || isSavingAll || hasValidationErrors
|
||||
}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
@ -459,7 +519,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
<RenameStreamDialog
|
||||
open={renameDialog !== null}
|
||||
streamName={renameDialog ?? ""}
|
||||
allStreamNames={Object.keys(editedStreams)}
|
||||
allStreamNames={Object.keys(liveStreams)}
|
||||
onRename={(oldName, newName) => {
|
||||
renameStream(oldName, newName);
|
||||
setRenameDialog(null);
|
||||
@ -469,7 +529,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
|
||||
<AddStreamDialog
|
||||
open={addStreamDialogOpen}
|
||||
allStreamNames={Object.keys(editedStreams)}
|
||||
allStreamNames={Object.keys(liveStreams)}
|
||||
onAdd={addStream}
|
||||
onClose={() => setAddStreamDialogOpen(false)}
|
||||
/>
|
||||
|
||||
@ -113,8 +113,8 @@
|
||||
--foreground: hsl(0, 0%, 100%);
|
||||
--foreground: 0, 0%, 100%;
|
||||
|
||||
--card: hsl(0, 0%, 15%);
|
||||
--card: 0, 0%, 15%;
|
||||
--card: hsl(0, 0%, 12%);
|
||||
--card: 0, 0%, 12%;
|
||||
|
||||
--card-foreground: hsl(210, 40%, 98%);
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user