mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
7 Commits
89a352197f
...
83158484c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83158484c1 | ||
|
|
ad9092d0da | ||
|
|
20705a3e97 | ||
|
|
f4ac063b37 | ||
|
|
2dcaeb6809 | ||
|
|
d007bd0a6f | ||
|
|
2420fdc4ce |
5
.gitignore
vendored
5
.gitignore
vendored
@ -22,3 +22,8 @@ core
|
|||||||
!/web/**/*.ts
|
!/web/**/*.ts
|
||||||
.idea/*
|
.idea/*
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# Auto-generated Docker Compose Generator config files
|
||||||
|
docs/src/components/DockerComposeGenerator/config/devices.ts
|
||||||
|
docs/src/components/DockerComposeGenerator/config/hardware.ts
|
||||||
|
docs/src/components/DockerComposeGenerator/config/ports.ts
|
||||||
|
|||||||
@ -87,43 +87,43 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
|||||||
# intel packages use zst compression so we need to update dpkg
|
# intel packages use zst compression so we need to update dpkg
|
||||||
apt-get install -y dpkg
|
apt-get install -y dpkg
|
||||||
|
|
||||||
# use intel apt intel packages
|
# use intel apt repo for libmfx1 (legacy QSV, pre-Gen12)
|
||||||
wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg
|
wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg
|
||||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list
|
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||||
apt-get -qq update
|
apt-get -qq update
|
||||||
|
|
||||||
# intel-media-va-driver-non-free is built from source in the
|
# intel-media-va-driver-non-free is built from source in the
|
||||||
# intel-media-driver Dockerfile stage for Battlemage (Xe2) support
|
# intel-media-driver Dockerfile stage for Battlemage (Xe2) support
|
||||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||||
libmfx1 libmfxgen1 libvpl2
|
libmfx1
|
||||||
|
rm -f /usr/share/keyrings/intel-graphics.gpg
|
||||||
|
rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||||
|
|
||||||
|
# upgrade libva2, oneVPL runtime, and libvpl2 from trixie for Battlemage support
|
||||||
|
echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list
|
||||||
|
apt-get -qq update
|
||||||
|
apt-get -qq install -y -t trixie libva2 libva-drm2 libzstd1
|
||||||
|
apt-get -qq install -y -t trixie libmfx-gen1.2 libvpl2
|
||||||
|
rm -f /etc/apt/sources.list.d/trixie.list
|
||||||
|
apt-get -qq update
|
||||||
apt-get -qq install -y ocl-icd-libopencl1
|
apt-get -qq install -y ocl-icd-libopencl1
|
||||||
|
|
||||||
# install libtbb12 for NPU support
|
# install libtbb12 for NPU support
|
||||||
apt-get -qq install -y libtbb12
|
apt-get -qq install -y libtbb12
|
||||||
|
|
||||||
rm -f /usr/share/keyrings/intel-graphics.gpg
|
# install legacy and standard intel compute packages
|
||||||
rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list
|
|
||||||
|
|
||||||
# install legacy and standard intel icd and level-zero-gpu
|
|
||||||
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
|
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
|
||||||
# newer intel packages (gmmlib 22.9+, igc 2.32+) require libstdc++ >= 13.1 and libzstd >= 1.5.5
|
|
||||||
echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list
|
|
||||||
apt-get -qq update
|
|
||||||
apt-get -qq install -y -t trixie libstdc++6 libzstd1
|
|
||||||
rm -f /etc/apt/sources.list.d/trixie.list
|
|
||||||
apt-get -qq update
|
|
||||||
|
|
||||||
# needed core package
|
# needed core package
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
|
||||||
dpkg -i libigdgmm12_22.9.0_amd64.deb
|
dpkg -i libigdgmm12_22.9.0_amd64.deb
|
||||||
rm libigdgmm12_22.9.0_amd64.deb
|
rm libigdgmm12_22.9.0_amd64.deb
|
||||||
|
|
||||||
# legacy packages
|
# legacy compute-runtime packages
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
|
||||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
|
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
|
||||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
|
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
|
||||||
# standard packages
|
# standard compute-runtime packages
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
|
||||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
|
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
|
||||||
@ -137,6 +137,10 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
|||||||
dpkg -i *.deb
|
dpkg -i *.deb
|
||||||
rm *.deb
|
rm *.deb
|
||||||
apt-get -qq install -f -y
|
apt-get -qq install -f -y
|
||||||
|
|
||||||
|
# Battlemage uses the xe kernel driver, but the VA-API driver is still iHD.
|
||||||
|
# The oneVPL runtime may look for a driver named after the kernel module.
|
||||||
|
ln -sf /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so /usr/lib/x86_64-linux-gnu/dri/xe_drv_video.so
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
|
|||||||
@ -42,7 +42,7 @@ opencv-python-headless == 4.11.0.*
|
|||||||
opencv-contrib-python == 4.11.0.*
|
opencv-contrib-python == 4.11.0.*
|
||||||
scipy == 1.16.*
|
scipy == 1.16.*
|
||||||
# OpenVino & ONNX
|
# OpenVino & ONNX
|
||||||
openvino == 2025.3.*
|
openvino == 2025.4.*
|
||||||
onnxruntime == 1.22.*
|
onnxruntime == 1.22.*
|
||||||
# Embeddings
|
# Embeddings
|
||||||
transformers == 4.45.*
|
transformers == 4.45.*
|
||||||
|
|||||||
@ -4,6 +4,8 @@ title: Installation
|
|||||||
---
|
---
|
||||||
|
|
||||||
import ShmCalculator from '@site/src/components/ShmCalculator'
|
import ShmCalculator from '@site/src/components/ShmCalculator'
|
||||||
|
import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator'
|
||||||
|
|
||||||
|
|
||||||
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
||||||
|
|
||||||
@ -71,6 +73,13 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### Docker Compose Generator
|
||||||
|
|
||||||
|
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
|
||||||
|
|
||||||
|
<DockerComposeGenerator/>
|
||||||
|
|
||||||
|
|
||||||
### Calculating required shm-size
|
### Calculating required shm-size
|
||||||
|
|
||||||
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
|
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
|
||||||
|
|||||||
@ -111,26 +111,16 @@ TCP ensures that all data packets arrive in the correct order. This is crucial f
|
|||||||
|
|
||||||
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
|
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
|
||||||
|
|
||||||
### Frigate hangs on startup with a "probing detect stream" message in the logs
|
### Frigate is slow to start up with a "probing detect stream" message in the logs
|
||||||
|
|
||||||
On startup, Frigate probes each camera's detect stream with OpenCV to auto-detect its resolution. OpenCV's FFmpeg backend may attempt RTSP over UDP during this probe regardless of the `-rtsp_transport tcp` in your `input_args` or preset. For cameras that do not respond to UDP (common on some Reolink models and others behind firewalls that block UDP), the probe can hang indefinitely and block Frigate from finishing startup, or it can return zeroed-out dimensions that show up as width `0` and height `0` in Camera Probe Info under System Metrics.
|
When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under System Metrics.
|
||||||
|
|
||||||
There are two ways to avoid this:
|
To skip the probe entirely and make startup instant, set `detect.width` and `detect.height` explicitly in your camera config:
|
||||||
|
|
||||||
1. Set `detect.width` and `detect.height` explicitly in your camera config. When both are set, Frigate skips the auto-detect probe entirely:
|
```yaml
|
||||||
|
cameras:
|
||||||
```yaml
|
my_camera:
|
||||||
cameras:
|
detect:
|
||||||
my_camera:
|
width: 1280
|
||||||
detect:
|
height: 720
|
||||||
width: 1280
|
```
|
||||||
height: 720
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Force OpenCV's FFmpeg backend to use TCP for RTSP by setting the environment variable on your Frigate container:
|
|
||||||
|
|
||||||
```
|
|
||||||
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a process-wide setting and applies to all cameras. If you have any cameras that require `preset-rtsp-udp`, use option 1 instead.
|
|
||||||
|
|||||||
9
docs/package-lock.json
generated
9
docs/package-lock.json
generated
@ -14,9 +14,11 @@
|
|||||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||||
"@inkeep/docusaurus": "^2.0.16",
|
"@inkeep/docusaurus": "^2.0.16",
|
||||||
"@mdx-js/react": "^3.1.0",
|
"@mdx-js/react": "^3.1.0",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@ -5747,6 +5749,11 @@
|
|||||||
"@types/istanbul-lib-report": "*"
|
"@types/istanbul-lib-report": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/js-yaml": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@ -12883,7 +12890,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build:config": "node scripts/build-config.mjs",
|
||||||
"docusaurus": "docusaurus",
|
"docusaurus": "docusaurus",
|
||||||
"start": "npm run regen-docs && docusaurus start --host 0.0.0.0",
|
"start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||||
"build": "npm run regen-docs && docusaurus build",
|
"build": "npm run build:config && npm run regen-docs && docusaurus build",
|
||||||
"swizzle": "docusaurus swizzle",
|
"swizzle": "docusaurus swizzle",
|
||||||
"deploy": "docusaurus deploy",
|
"deploy": "docusaurus deploy",
|
||||||
"clear": "docusaurus clear",
|
"clear": "docusaurus clear",
|
||||||
@ -23,9 +24,11 @@
|
|||||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||||
"@inkeep/docusaurus": "^2.0.16",
|
"@inkeep/docusaurus": "^2.0.16",
|
||||||
"@mdx-js/react": "^3.1.0",
|
"@mdx-js/react": "^3.1.0",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
64
docs/scripts/build-config.mjs
Normal file
64
docs/scripts/build-config.mjs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build script: reads config.yaml and generates TypeScript files
|
||||||
|
* for the Docker Compose Generator.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/build-config.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CONFIG_DIR = path.resolve(__dirname, "../src/components/DockerComposeGenerator/config");
|
||||||
|
const YAML_PATH = path.join(CONFIG_DIR, "config.yaml");
|
||||||
|
|
||||||
|
// Read & parse YAML
|
||||||
|
const raw = fs.readFileSync(YAML_PATH, "utf8");
|
||||||
|
const config = yaml.load(raw);
|
||||||
|
|
||||||
|
if (!config.devices || !config.hardware || !config.ports) {
|
||||||
|
console.error("config.yaml must contain 'devices', 'hardware', and 'ports' sections.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a .ts file from a section of the YAML config.
|
||||||
|
*/
|
||||||
|
function generateTsFile(sectionName, items, typeName, varName, mapVarName, yamlFilename) {
|
||||||
|
const jsonItems = JSON.stringify(items, null, 2);
|
||||||
|
// Indent JSON to fit inside the array literal
|
||||||
|
const indented = jsonItems
|
||||||
|
.split("\n")
|
||||||
|
.map((line, i) => (i === 0 ? line : " " + line))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const content = `/**
|
||||||
|
* AUTO-GENERATED FILE — do not edit directly.
|
||||||
|
* Source: ${yamlFilename}
|
||||||
|
* To update, edit the YAML file and run: npm run build:config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ${typeName} } from "./types";
|
||||||
|
|
||||||
|
export const ${varName}: ${typeName}[] = ${indented};
|
||||||
|
|
||||||
|
/** Lookup map for quick access by ID */
|
||||||
|
export const ${mapVarName}: Map<string, ${typeName}> = new Map(${varName}.map((item) => [item.id, item]));
|
||||||
|
`;
|
||||||
|
|
||||||
|
const outPath = path.join(CONFIG_DIR, `${sectionName}.ts`);
|
||||||
|
fs.writeFileSync(outPath, content, "utf8");
|
||||||
|
console.log(` ✓ Generated ${sectionName}.ts (${items.length} items)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Building config from config.yaml...");
|
||||||
|
|
||||||
|
generateTsFile("devices", config.devices, "DeviceConfig", "devices", "deviceMap", "config.yaml");
|
||||||
|
generateTsFile("hardware", config.hardware, "HardwareOption", "hardwareOptions", "hardwareMap", "config.yaml");
|
||||||
|
generateTsFile("ports", config.ports, "PortConfig", "ports", "portMap", "config.yaml");
|
||||||
|
|
||||||
|
console.log("Done!");
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Admonition from "@theme/Admonition";
|
||||||
|
import DeviceSelector from "./components/DeviceSelector";
|
||||||
|
import HardwareOptions from "./components/HardwareOptions";
|
||||||
|
import PortConfigSection from "./components/PortConfig";
|
||||||
|
import StoragePaths from "./components/StoragePaths";
|
||||||
|
import NvidiaGpuConfig from "./components/NvidiaGpuConfig";
|
||||||
|
import OtherOptions from "./components/OtherOptions";
|
||||||
|
import GeneratedOutput from "./components/GeneratedOutput";
|
||||||
|
import { useConfigGenerator } from "./hooks/useConfigGenerator";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple markdown-link-to-React renderer for help text.
|
||||||
|
* Only supports [text](url) syntax — no nested brackets.
|
||||||
|
*/
|
||||||
|
function renderHelpText(text: string): React.ReactNode {
|
||||||
|
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||||
|
if (match) {
|
||||||
|
return (
|
||||||
|
<a key={i} href={match[2]}>
|
||||||
|
{match[1]}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DockerComposeGenerator() {
|
||||||
|
const {
|
||||||
|
deviceId, device, hardwareEnabled,
|
||||||
|
portEnabled, port5000Confirmed,
|
||||||
|
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||||
|
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||||
|
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
||||||
|
hasAnyHardware, generatedYaml,
|
||||||
|
selectDevice, toggleHardware, togglePort, setPort5000Confirmed,
|
||||||
|
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
||||||
|
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
||||||
|
setRtspPassword, setTimezone, isHardwareDisabled,
|
||||||
|
} = useConfigGenerator();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.generator}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<DeviceSelector selectedId={deviceId} onSelect={selectDevice} />
|
||||||
|
|
||||||
|
{device.helpText && (
|
||||||
|
<Admonition type={device.helpType || "info"}>
|
||||||
|
{renderHelpText(device.helpText)}
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{device.needsNvidiaConfig && (
|
||||||
|
<NvidiaGpuConfig
|
||||||
|
gpuCount={nvidiaGpuCount}
|
||||||
|
gpuDeviceId={nvidiaGpuDeviceId}
|
||||||
|
gpuDeviceIdError={gpuDeviceIdError}
|
||||||
|
onGpuCountChange={handleNvidiaGpuCountChange}
|
||||||
|
onGpuDeviceIdChange={handleNvidiaGpuDeviceIdChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HardwareOptions
|
||||||
|
deviceId={deviceId}
|
||||||
|
hardwareEnabled={hardwareEnabled}
|
||||||
|
onToggle={toggleHardware}
|
||||||
|
isDisabled={isHardwareDisabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StoragePaths
|
||||||
|
configPath={configPath}
|
||||||
|
mediaPath={mediaPath}
|
||||||
|
configPathError={configPathError}
|
||||||
|
mediaPathError={mediaPathError}
|
||||||
|
onConfigPathChange={handleConfigPathChange}
|
||||||
|
onMediaPathChange={handleMediaPathChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PortConfigSection
|
||||||
|
portEnabled={portEnabled}
|
||||||
|
port5000Confirmed={port5000Confirmed}
|
||||||
|
onTogglePort={togglePort}
|
||||||
|
onConfirm5000={setPort5000Confirmed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OtherOptions
|
||||||
|
rtspPassword={rtspPassword}
|
||||||
|
timezone={timezone}
|
||||||
|
shmSize={shmSize}
|
||||||
|
shmSizeError={shmSizeError}
|
||||||
|
onRtspPasswordChange={setRtspPassword}
|
||||||
|
onTimezoneChange={setTimezone}
|
||||||
|
onShmSizeChange={handleShmSizeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GeneratedOutput
|
||||||
|
yaml={generatedYaml}
|
||||||
|
configPath={configPath}
|
||||||
|
mediaPath={mediaPath}
|
||||||
|
hasAnyHardware={hasAnyHardware}
|
||||||
|
deviceId={deviceId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useColorMode } from "@docusaurus/theme-common";
|
||||||
|
import { devices } from "../config";
|
||||||
|
import type { DeviceConfig } from "../config";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the icon type from the icon string:
|
||||||
|
* - Starts with "<svg" → inline SVG
|
||||||
|
* - Starts with "/" or "http" → image URL/path
|
||||||
|
* - Otherwise → emoji text
|
||||||
|
*/
|
||||||
|
function getIconType(icon: string): "svg" | "image" | "emoji" {
|
||||||
|
const trimmed = icon.trim();
|
||||||
|
if (trimmed.startsWith("<svg")) return "svg";
|
||||||
|
if (trimmed.startsWith("/") || trimmed.startsWith("http://") || trimmed.startsWith("https://")) return "image";
|
||||||
|
return "emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the style object contains background-* properties,
|
||||||
|
* indicating the image should be rendered as a CSS background-image
|
||||||
|
* rather than an <img> tag.
|
||||||
|
*/
|
||||||
|
function hasBackgroundProps(style: React.CSSProperties | undefined): boolean {
|
||||||
|
if (!style) return false;
|
||||||
|
return Object.keys(style).some((key) => {
|
||||||
|
const k = key.toLowerCase().replace(/-/g, "");
|
||||||
|
return k === "backgroundsize" || k === "backgroundposition" || k === "backgroundrepeat" || k === "backgroundimage";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a style object to CSS custom properties (e.g. { width: "24px" } → { "--svg-width": "24px" })
|
||||||
|
* so they can be consumed by CSS rules targeting child elements like <svg>.
|
||||||
|
*/
|
||||||
|
function toCssVars(style: React.CSSProperties | undefined, prefix: string): React.CSSProperties {
|
||||||
|
if (!style) return {};
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(style)) {
|
||||||
|
const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
||||||
|
vars[`--${prefix}-${cssKey}`] = value;
|
||||||
|
}
|
||||||
|
return vars as React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceIcon({ device }: { device: DeviceConfig }) {
|
||||||
|
const { isDarkTheme } = useColorMode();
|
||||||
|
const iconStr = isDarkTheme && device.iconDark ? device.iconDark : device.icon;
|
||||||
|
const iconStyle = (isDarkTheme && device.iconDarkStyle
|
||||||
|
? device.iconDarkStyle
|
||||||
|
: device.iconStyle) as React.CSSProperties | undefined;
|
||||||
|
const svgStyle = (isDarkTheme && device.svgDarkStyle
|
||||||
|
? device.svgDarkStyle
|
||||||
|
: device.svgStyle) as React.CSSProperties | undefined;
|
||||||
|
|
||||||
|
const iconType = getIconType(iconStr);
|
||||||
|
|
||||||
|
if (iconType === "svg") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.deviceIconSvg}
|
||||||
|
style={{ ...iconStyle, ...toCssVars(svgStyle, "svg") }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: iconStr }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconType === "image") {
|
||||||
|
// When iconStyle contains background-* properties, render as background-image
|
||||||
|
// on the container div instead of an <img> tag, enabling background-size/position control.
|
||||||
|
if (hasBackgroundProps(iconStyle)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.deviceIconImage}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${iconStr})`,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundSize: "contain",
|
||||||
|
...iconStyle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.deviceIconImage}>
|
||||||
|
<img src={iconStr} alt={device.name} style={iconStyle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.deviceIcon} style={iconStyle}>
|
||||||
|
{iconStr}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceCard({
|
||||||
|
device,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
device: DeviceConfig;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.deviceCard} ${active ? styles.deviceCardActive : ""}`}
|
||||||
|
onClick={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeviceIcon device={device} />
|
||||||
|
<div className={styles.deviceName}>{device.name}</div>
|
||||||
|
<div className={styles.deviceDesc}>{device.description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceSelector({ selectedId, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.formSection}>
|
||||||
|
<h4>Device Type</h4>
|
||||||
|
<div className={styles.deviceGrid}>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<DeviceCard
|
||||||
|
key={d.id}
|
||||||
|
device={d}
|
||||||
|
active={selectedId === d.id}
|
||||||
|
onClick={() => onSelect(d.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import CodeBlock from "@theme/CodeBlock";
|
||||||
|
import Admonition from "@theme/Admonition";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
yaml: string;
|
||||||
|
configPath: string;
|
||||||
|
mediaPath: string;
|
||||||
|
hasAnyHardware: boolean;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GeneratedOutput({
|
||||||
|
yaml,
|
||||||
|
configPath,
|
||||||
|
mediaPath,
|
||||||
|
hasAnyHardware,
|
||||||
|
deviceId,
|
||||||
|
}: Props) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(yaml).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}, [yaml]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.resultSection}>
|
||||||
|
<div className={styles.resultHeader}>
|
||||||
|
<h4>Generated Configuration</h4>
|
||||||
|
<button className="button button--primary button--sm" onClick={handleCopy}>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!configPath && (
|
||||||
|
<Admonition type="tip">
|
||||||
|
<p>You haven't specified a config file directory. You may want to modify the default path.</p>
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
{!mediaPath && (
|
||||||
|
<Admonition type="tip">
|
||||||
|
<p>You haven't specified a recording storage directory. You may want to modify the default path.</p>
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
{deviceId === "stable" && !hasAnyHardware && (
|
||||||
|
<Admonition type="warning">
|
||||||
|
<p>You haven't selected any hardware acceleration. Please check if you have supported hardware available.</p>
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CodeBlock language="yaml" title="docker-compose.yml">
|
||||||
|
{yaml}
|
||||||
|
</CodeBlock>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { hardwareOptions } from "../config";
|
||||||
|
import type { HardwareOption } from "../config";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deviceId: string;
|
||||||
|
hardwareEnabled: Record<string, boolean>;
|
||||||
|
onToggle: (hwId: string) => void;
|
||||||
|
isDisabled: (hwId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDescription(text: string): React.ReactNode {
|
||||||
|
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||||
|
if (match) {
|
||||||
|
return <a key={i} href={match[2]}>{match[1]}</a>;
|
||||||
|
}
|
||||||
|
return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function HardwareCheckbox({
|
||||||
|
hw, disabled, checked, onToggle,
|
||||||
|
}: {
|
||||||
|
hw: HardwareOption; disabled: boolean; checked: boolean; onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.hardwareItem}>
|
||||||
|
<label className={`${styles.checkboxLabel} ${disabled ? styles.checkboxDisabled : ""}`}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={onToggle} disabled={disabled} />
|
||||||
|
<span>{hw.label}</span>
|
||||||
|
</label>
|
||||||
|
{checked && hw.description && (
|
||||||
|
<div className={styles.hardwareDescription}>{renderDescription(hw.description)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HardwareOptions({ deviceId, hardwareEnabled, onToggle, isDisabled }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.formSection}>
|
||||||
|
<h4>Generic Hardware Acceleration</h4>
|
||||||
|
{deviceId !== "stable" && (
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
Some options have been auto-configured based on your device type.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.checkboxGrid}>
|
||||||
|
{hardwareOptions.map((hw) => {
|
||||||
|
const disabled = isDisabled(hw.id);
|
||||||
|
const checked = disabled ? false : !!hardwareEnabled[hw.id];
|
||||||
|
return (
|
||||||
|
<HardwareCheckbox key={hw.id} hw={hw} disabled={disabled} checked={checked} onToggle={() => onToggle(hw.id)} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gpuCount: string;
|
||||||
|
gpuDeviceId: string;
|
||||||
|
gpuDeviceIdError: boolean;
|
||||||
|
onGpuCountChange: (value: string) => void;
|
||||||
|
onGpuDeviceIdChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NvidiaGpuConfig({
|
||||||
|
gpuCount,
|
||||||
|
gpuDeviceId,
|
||||||
|
gpuDeviceIdError,
|
||||||
|
onGpuCountChange,
|
||||||
|
onGpuDeviceIdChange,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.nvidiaConfig}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="dcg-gpu-count" className={styles.label}>
|
||||||
|
GPU count:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dcg-gpu-count"
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={gpuCount}
|
||||||
|
placeholder="1 or all"
|
||||||
|
onChange={(e) => onGpuCountChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
Enter a number (e.g. 1, 2, 3) or "all" to use all GPUs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{gpuCount !== "all" && (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="dcg-gpu-device-id" className={styles.label}>
|
||||||
|
GPU device IDs (required, comma-separated):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dcg-gpu-device-id"
|
||||||
|
type="text"
|
||||||
|
className={`${styles.input} ${gpuDeviceIdError ? styles.inputError : ""}`}
|
||||||
|
value={gpuDeviceId}
|
||||||
|
placeholder="0"
|
||||||
|
onChange={(e) => onGpuDeviceIdChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{gpuDeviceIdError ? (
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
⚠️ GPU device IDs are required when GPU count is a number
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
Single GPU: 0 | Multiple GPUs: 0,1,2
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import CodeInline from "@theme/CodeInline";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rtspPassword: string;
|
||||||
|
timezone: string;
|
||||||
|
shmSize: string;
|
||||||
|
shmSizeError: boolean;
|
||||||
|
onRtspPasswordChange: (value: string) => void;
|
||||||
|
onTimezoneChange: (value: string) => void;
|
||||||
|
onShmSizeChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OtherOptions({
|
||||||
|
rtspPassword,
|
||||||
|
timezone,
|
||||||
|
shmSize,
|
||||||
|
shmSizeError,
|
||||||
|
onRtspPasswordChange,
|
||||||
|
onTimezoneChange,
|
||||||
|
onShmSizeChange,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.formSection}>
|
||||||
|
<h4>Other Options</h4>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="dcg-rtsp-password" className={styles.label}>
|
||||||
|
RTSP password:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dcg-rtsp-password"
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={rtspPassword}
|
||||||
|
placeholder="password"
|
||||||
|
onChange={(e) => onRtspPasswordChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
Used as{" "}
|
||||||
|
<CodeInline>{"{FRIGATE_RTSP_PASSWORD}"}</CodeInline>{" "}
|
||||||
|
in the config file to reference camera stream passwords. This is NOT
|
||||||
|
the Frigate login password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="dcg-timezone" className={styles.label}>
|
||||||
|
Timezone:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dcg-timezone"
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={timezone}
|
||||||
|
placeholder={Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"}
|
||||||
|
onChange={(e) => onTimezoneChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="dcg-shm-size" className={styles.label}>
|
||||||
|
Shared memory (SHM):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dcg-shm-size"
|
||||||
|
type="text"
|
||||||
|
className={`${styles.input} ${shmSizeError ? styles.inputError : ""}`}
|
||||||
|
value={shmSize}
|
||||||
|
placeholder="512mb"
|
||||||
|
onChange={(e) => onShmSizeChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{shmSizeError ? (
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
⚠️ Invalid format. Use a number followed by a unit (e.g. 512mb, 1gb)
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
See{" "}
|
||||||
|
<a href="/frigate/installation#calculating-required-shm-size">
|
||||||
|
calculating required SHM size
|
||||||
|
</a>{" "}
|
||||||
|
for the correct value.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Admonition from "@theme/Admonition";
|
||||||
|
import { ports } from "../config";
|
||||||
|
import { useCooldown } from "../hooks/useCooldown";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portEnabled: Record<string, boolean>;
|
||||||
|
port5000Confirmed: boolean;
|
||||||
|
onTogglePort: (portId: string) => void;
|
||||||
|
onConfirm5000: (confirmed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Port5000Confirmation({
|
||||||
|
portEnabled,
|
||||||
|
confirmed,
|
||||||
|
onToggle,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
portEnabled: boolean;
|
||||||
|
confirmed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onConfirm: (confirmed: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { remaining, start, stop } = useCooldown(10);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (portEnabled) {
|
||||||
|
start();
|
||||||
|
} else {
|
||||||
|
stop();
|
||||||
|
onConfirm(false);
|
||||||
|
}
|
||||||
|
return stop;
|
||||||
|
}, [portEnabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.portSection}>
|
||||||
|
{portEnabled && (
|
||||||
|
<Admonition type="danger">
|
||||||
|
<p>
|
||||||
|
Exposing port 5000 allows <strong>unauthenticated access</strong> to
|
||||||
|
your Frigate instance. Anyone on your network (or the internet if you
|
||||||
|
have a public IP) could access it without credentials.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This may lead to <strong>unauthorized access</strong>,{" "}
|
||||||
|
<strong>privacy leaks</strong>, or further attacks. Ensure you have
|
||||||
|
proper firewall rules or VPN in place.
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className={`${styles.checkboxLabel} ${remaining > 0 ? styles.checkboxDisabled : ""}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => onConfirm(e.target.checked)}
|
||||||
|
disabled={remaining > 0}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
I understand the risk and confirm enabling port 5000
|
||||||
|
{remaining > 0 && ` (${remaining}s)`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={portEnabled}
|
||||||
|
onChange={onToggle}
|
||||||
|
/>
|
||||||
|
<span>Port 5000 (unauthenticated access)</span>
|
||||||
|
<span className={styles.warningBadge}>⚠️ Expose carefully</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortConfigSection({
|
||||||
|
portEnabled,
|
||||||
|
port5000Confirmed,
|
||||||
|
onTogglePort,
|
||||||
|
onConfirm5000,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.formSection}>
|
||||||
|
<h4>Port Configuration</h4>
|
||||||
|
|
||||||
|
{/* All ports except 5000 */}
|
||||||
|
<div className={styles.checkboxGrid}>
|
||||||
|
{ports
|
||||||
|
.filter((p) => p.id !== "5000")
|
||||||
|
.map((port) => (
|
||||||
|
<div key={port.id} className={styles.hardwareItem}>
|
||||||
|
<label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!portEnabled[port.id]}
|
||||||
|
onChange={() => onTogglePort(port.id)}
|
||||||
|
disabled={port.locked}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{port.locked && "🔒 "}
|
||||||
|
Port {port.host}
|
||||||
|
{port.protocol !== "tcp" && `/${port.protocol}`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{port.description && (
|
||||||
|
<div className={styles.hardwareDescription}>{port.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Port 5000 with special warning — placed last */}
|
||||||
|
<Port5000Confirmation
|
||||||
|
portEnabled={!!portEnabled["5000"]}
|
||||||
|
confirmed={port5000Confirmed}
|
||||||
|
onToggle={() => onTogglePort("5000")}
|
||||||
|
onConfirm={onConfirm5000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
configPath: string;
|
||||||
|
mediaPath: string;
|
||||||
|
configPathError: boolean;
|
||||||
|
mediaPathError: boolean;
|
||||||
|
onConfigPathChange: (value: string) => void;
|
||||||
|
onMediaPathChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoragePaths({
|
||||||
|
configPath,
|
||||||
|
mediaPath,
|
||||||
|
configPathError,
|
||||||
|
mediaPathError,
|
||||||
|
onConfigPathChange,
|
||||||
|
onMediaPathChange,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.formSection}>
|
||||||
|
<h4>Storage Paths</h4>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="dcg-config-path" className={styles.label}>
|
||||||
|
Config / DB / model cache directory:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dcg-config-path"
|
||||||
|
type="text"
|
||||||
|
className={`${styles.input} ${configPathError ? styles.inputError : ""}`}
|
||||||
|
value={configPath}
|
||||||
|
placeholder="/path/to/your/config"
|
||||||
|
onChange={(e) => onConfigPathChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{configPathError && (
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
⚠️ Path contains invalid characters. Only letters, numbers,
|
||||||
|
underscores, hyphens, slashes, and dots are allowed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="dcg-media-path" className={styles.label}>
|
||||||
|
Recording storage directory:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dcg-media-path"
|
||||||
|
type="text"
|
||||||
|
className={`${styles.input} ${mediaPathError ? styles.inputError : ""}`}
|
||||||
|
value={mediaPath}
|
||||||
|
placeholder="/path/to/your/storage"
|
||||||
|
onChange={(e) => onMediaPathChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{mediaPathError && (
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
⚠️ Path contains invalid characters. Only letters, numbers,
|
||||||
|
underscores, hyphens, slashes, and dots are allowed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
docs/src/components/DockerComposeGenerator/config/config.yaml
Normal file
308
docs/src/components/DockerComposeGenerator/config/config.yaml
Normal file
File diff suppressed because one or more lines are too long
12
docs/src/components/DockerComposeGenerator/config/index.ts
Normal file
12
docs/src/components/DockerComposeGenerator/config/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export { devices, deviceMap } from "./devices";
|
||||||
|
export { hardwareOptions, hardwareMap } from "./hardware";
|
||||||
|
export { ports, portMap } from "./ports";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DeviceConfig,
|
||||||
|
DeviceMapping,
|
||||||
|
VolumeMapping,
|
||||||
|
HardwareOption,
|
||||||
|
PortConfig,
|
||||||
|
NvidiaDeployConfig,
|
||||||
|
} from "./types";
|
||||||
158
docs/src/components/DockerComposeGenerator/config/types.ts
Normal file
158
docs/src/components/DockerComposeGenerator/config/types.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Type definitions for the Docker Compose Generator configuration.
|
||||||
|
* All device, hardware, and port options are declaratively defined
|
||||||
|
* so that adding a new device only requires editing config files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A single device mapping entry (e.g. /dev/dri:/dev/dri) */
|
||||||
|
export interface DeviceMapping {
|
||||||
|
/** Host device path */
|
||||||
|
host: string;
|
||||||
|
/** Container device path (defaults to host if omitted) */
|
||||||
|
container?: string;
|
||||||
|
/** Inline comment for this device line */
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single volume mapping entry */
|
||||||
|
export interface VolumeMapping {
|
||||||
|
/** Host path */
|
||||||
|
host: string;
|
||||||
|
/** Container path */
|
||||||
|
container: string;
|
||||||
|
/** Whether the mount is read-only */
|
||||||
|
readOnly?: boolean;
|
||||||
|
/** Inline comment */
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NVIDIA deploy configuration for docker-compose */
|
||||||
|
export interface NvidiaDeployConfig {
|
||||||
|
/** "all" or a specific number */
|
||||||
|
count: string;
|
||||||
|
/** Specific GPU device IDs (when count is a number) */
|
||||||
|
deviceIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full device type definition */
|
||||||
|
export interface DeviceConfig {
|
||||||
|
/** Unique identifier, e.g. "intel" */
|
||||||
|
id: string;
|
||||||
|
/** Display name, e.g. "Intel GPU" */
|
||||||
|
name: string;
|
||||||
|
/** Short description */
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Icon for the device card. Supports:
|
||||||
|
* - Emoji string (e.g. "🖥️")
|
||||||
|
* - Image URL or static path (e.g. "/img/intel.svg", "https://example.com/icon.png")
|
||||||
|
* - Inline SVG markup (e.g. "<svg>...</svg>")
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
/**
|
||||||
|
* Additional CSS properties applied to the icon element.
|
||||||
|
* - For image-type icons: if any `background-*` property (e.g. `background-size`,
|
||||||
|
* `background-position`) is present, the image is rendered as a CSS `background-image`
|
||||||
|
* on the container div, enabling full background positioning control.
|
||||||
|
* Otherwise the image is rendered as an `<img>` tag and styles apply to it.
|
||||||
|
* - For emoji/SVG icons: styles apply to the container div.
|
||||||
|
*/
|
||||||
|
iconStyle?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Additional CSS properties applied directly to the inner `<svg>` element
|
||||||
|
* when the icon is an inline SVG. Use this to override the default
|
||||||
|
* `width: 100%; height: 100%` or set `fill`, `transform`, etc.
|
||||||
|
* Ignored for emoji and image-type icons.
|
||||||
|
*/
|
||||||
|
svgStyle?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Icon for dark mode. Same format as `icon`. When provided, this icon
|
||||||
|
* replaces `icon` when the user is in dark mode.
|
||||||
|
*/
|
||||||
|
iconDark?: string;
|
||||||
|
/** Additional CSS properties for the dark mode icon container */
|
||||||
|
iconDarkStyle?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* SVG-specific styles for dark mode. Same as `svgStyle` but applied
|
||||||
|
* when dark mode is active. Merged over `svgStyle` in dark mode.
|
||||||
|
*/
|
||||||
|
svgDarkStyle?: Record<string, string>;
|
||||||
|
/** Docker image tag, e.g. "stable" */
|
||||||
|
imageTag: string;
|
||||||
|
/**
|
||||||
|
* Image tag suffix appended to the base tag.
|
||||||
|
* e.g. "-standard-arm64" produces "stable-standard-arm64"
|
||||||
|
*/
|
||||||
|
imageTagSuffix?: string;
|
||||||
|
/** Hardware option IDs to auto-enable when this device is selected */
|
||||||
|
autoHardware: string[];
|
||||||
|
/** Help text shown as an admonition when this device is selected */
|
||||||
|
helpText?: string;
|
||||||
|
/** Admonition type for help text */
|
||||||
|
helpType?: "info" | "warning" | "danger";
|
||||||
|
/** Device mappings always added for this device type */
|
||||||
|
devices?: DeviceMapping[];
|
||||||
|
/** Volume mappings always added for this device type */
|
||||||
|
volumes?: VolumeMapping[];
|
||||||
|
/** Extra environment variables for this device type */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
/** NVIDIA deploy config (only for tensorrt) */
|
||||||
|
nvidiaDeploy?: NvidiaDeployConfig;
|
||||||
|
/** Runtime setting, e.g. "nvidia" for Jetson */
|
||||||
|
runtime?: string;
|
||||||
|
/** Extra hosts entries, e.g. "host.docker.internal:host-gateway" */
|
||||||
|
extraHosts?: string[];
|
||||||
|
/** Security options, e.g. ["apparmor=unconfined"] */
|
||||||
|
securityOpt?: string[];
|
||||||
|
/** Whether this device type needs the NVIDIA GPU config UI */
|
||||||
|
needsNvidiaConfig?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic hardware acceleration option definition */
|
||||||
|
export interface HardwareOption {
|
||||||
|
/** Unique identifier, e.g. "usbCoral" */
|
||||||
|
id: string;
|
||||||
|
/** Display label */
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* Description shown below the checkbox when this option is enabled.
|
||||||
|
* Supports markdown link syntax: [text](url)
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
/** Device IDs that disable this option */
|
||||||
|
disabledWhen?: string[];
|
||||||
|
/** Device mappings added when this option is enabled */
|
||||||
|
devices?: DeviceMapping[];
|
||||||
|
/** Volume mappings added when this option is enabled */
|
||||||
|
volumes?: VolumeMapping[];
|
||||||
|
/** Extra environment variables */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port definition */
|
||||||
|
export interface PortConfig {
|
||||||
|
/** Unique identifier (also the default host port as string) */
|
||||||
|
id: string;
|
||||||
|
/** Host port number */
|
||||||
|
host: number;
|
||||||
|
/** Container port number */
|
||||||
|
container: number;
|
||||||
|
/** Protocol */
|
||||||
|
protocol?: "tcp" | "udp";
|
||||||
|
/** Description of the port's purpose */
|
||||||
|
description: string;
|
||||||
|
/** Whether enabled by default */
|
||||||
|
defaultEnabled: boolean;
|
||||||
|
/** Whether this port is locked (always enabled, cannot be toggled off) */
|
||||||
|
locked?: boolean;
|
||||||
|
/** Whether this port requires a confirmation step before enabling */
|
||||||
|
requiresConfirmation?: boolean;
|
||||||
|
/** Admonition type for the warning */
|
||||||
|
warningType?: "warning" | "danger";
|
||||||
|
/** Warning content (markdown) */
|
||||||
|
warningContent?: string;
|
||||||
|
/** Confirmation checkbox label */
|
||||||
|
confirmationLabel?: string;
|
||||||
|
/** Cooldown in seconds before the confirmation checkbox becomes available */
|
||||||
|
cooldownSeconds?: number;
|
||||||
|
}
|
||||||
246
docs/src/components/DockerComposeGenerator/generator/index.ts
Normal file
246
docs/src/components/DockerComposeGenerator/generator/index.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import type {
|
||||||
|
DeviceConfig,
|
||||||
|
DeviceMapping,
|
||||||
|
VolumeMapping,
|
||||||
|
} from "../config/types";
|
||||||
|
import { hardwareMap } from "../config";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GeneratorInput {
|
||||||
|
device: DeviceConfig;
|
||||||
|
selectedHardware: string[];
|
||||||
|
enabledPorts: string[];
|
||||||
|
configPath: string;
|
||||||
|
mediaPath: string;
|
||||||
|
rtspPassword: string;
|
||||||
|
timezone: string;
|
||||||
|
shmSize: string;
|
||||||
|
nvidiaGpuCount?: string;
|
||||||
|
nvidiaGpuDeviceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function deviceLine(dm: DeviceMapping): string {
|
||||||
|
const host = dm.host;
|
||||||
|
const container = dm.container ?? dm.host;
|
||||||
|
const mapping = host === container ? host : `${host}:${container}`;
|
||||||
|
const comment = dm.comment ? ` # ${dm.comment}` : "";
|
||||||
|
return ` - ${mapping}${comment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function volumeLine(vm: VolumeMapping): string {
|
||||||
|
const ro = vm.readOnly ? ":ro" : "";
|
||||||
|
const comment = vm.comment ? ` # ${vm.comment}` : "";
|
||||||
|
return ` - ${vm.host}:${vm.container}${ro}${comment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// YAML builder — each section returns an array of lines
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildImage(device: DeviceConfig): string[] {
|
||||||
|
const tag = device.imageTagSuffix
|
||||||
|
? `${device.imageTag}${device.imageTagSuffix}`
|
||||||
|
: device.imageTag;
|
||||||
|
return [` image: ghcr.io/blakeblackshear/frigate:${tag}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDevices(
|
||||||
|
device: DeviceConfig,
|
||||||
|
hwDevices: DeviceMapping[]
|
||||||
|
): string[] {
|
||||||
|
const all: DeviceMapping[] = [
|
||||||
|
...(device.devices ?? []),
|
||||||
|
...hwDevices,
|
||||||
|
];
|
||||||
|
if (all.length === 0) return [];
|
||||||
|
return [
|
||||||
|
" devices:",
|
||||||
|
...all.map(deviceLine),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVolumes(
|
||||||
|
device: DeviceConfig,
|
||||||
|
hwVolumes: VolumeMapping[],
|
||||||
|
configPath: string,
|
||||||
|
mediaPath: string
|
||||||
|
): string[] {
|
||||||
|
const all: VolumeMapping[] = [
|
||||||
|
...(device.volumes ?? []),
|
||||||
|
...hwVolumes,
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
" volumes:",
|
||||||
|
" - /etc/localtime:/etc/localtime:ro # Sync host time",
|
||||||
|
` - ${configPath}:/config # Config file directory`,
|
||||||
|
` - ${mediaPath}:/media/frigate # Recording storage directory`,
|
||||||
|
" - type: tmpfs # 1GB in-memory filesystem for recording segment storage",
|
||||||
|
" target: /tmp/cache",
|
||||||
|
" tmpfs:",
|
||||||
|
" size: 1000000000",
|
||||||
|
...all.map(volumeLine),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPorts(enabledPorts: string[]): string[] {
|
||||||
|
return [
|
||||||
|
" ports:",
|
||||||
|
...enabledPorts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnvironment(
|
||||||
|
device: DeviceConfig,
|
||||||
|
hwEnv: Record<string, string>,
|
||||||
|
rtspPassword: string,
|
||||||
|
timezone: string
|
||||||
|
): string[] {
|
||||||
|
const allEnv: Record<string, string> = {
|
||||||
|
...hwEnv,
|
||||||
|
...(device.env ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
" environment:",
|
||||||
|
` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own`,
|
||||||
|
` TZ: "${timezone}" # Timezone`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(allEnv)) {
|
||||||
|
lines.push(` ${key}: "${value}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] {
|
||||||
|
if (device.id === "stable-tensorrt") {
|
||||||
|
const count = input.nvidiaGpuCount || "all";
|
||||||
|
const isAll = count.toLowerCase() === "all";
|
||||||
|
const deviceId = input.nvidiaGpuDeviceId?.trim();
|
||||||
|
|
||||||
|
if (isAll) {
|
||||||
|
return [
|
||||||
|
" deploy:",
|
||||||
|
" resources:",
|
||||||
|
" reservations:",
|
||||||
|
" devices:",
|
||||||
|
" - driver: nvidia",
|
||||||
|
" count: all # Use all GPUs",
|
||||||
|
" capabilities: [gpu]",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceId) {
|
||||||
|
const ids = deviceId
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => `'${s}'`)
|
||||||
|
.join(", ");
|
||||||
|
return [
|
||||||
|
" deploy:",
|
||||||
|
" resources:",
|
||||||
|
" reservations:",
|
||||||
|
" devices:",
|
||||||
|
" - driver: nvidia",
|
||||||
|
` device_ids: [${ids}] # GPU device IDs`,
|
||||||
|
` count: ${count} # GPU count`,
|
||||||
|
" capabilities: [gpu]",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
" deploy:",
|
||||||
|
" resources:",
|
||||||
|
" reservations:",
|
||||||
|
" devices:",
|
||||||
|
" - driver: nvidia",
|
||||||
|
` count: ${count} # GPU count`,
|
||||||
|
" capabilities: [gpu]",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuntime(device: DeviceConfig): string[] {
|
||||||
|
if (device.runtime) {
|
||||||
|
return [` runtime: ${device.runtime}`];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtraHosts(device: DeviceConfig): string[] {
|
||||||
|
if (!device.extraHosts?.length) return [];
|
||||||
|
return [
|
||||||
|
" extra_hosts:",
|
||||||
|
...device.extraHosts.map(
|
||||||
|
(h, i) =>
|
||||||
|
` - "${h}"${i === 0 ? " # Required to talk to the NPU detector" : ""}`
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSecurityOpt(device: DeviceConfig): string[] {
|
||||||
|
if (!device.securityOpt?.length) return [];
|
||||||
|
return [
|
||||||
|
" security_opt:",
|
||||||
|
...device.securityOpt.map((s) => ` - ${s}`),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a docker-compose YAML string from the given input.
|
||||||
|
* The output is pure YAML with inline comments (no Shiki annotations).
|
||||||
|
*/
|
||||||
|
export function generateDockerCompose(input: GeneratorInput): string {
|
||||||
|
const { device } = input;
|
||||||
|
|
||||||
|
// Collect hardware-level devices, volumes, and env
|
||||||
|
const hwDevices: DeviceMapping[] = [];
|
||||||
|
const hwVolumes: VolumeMapping[] = [];
|
||||||
|
const hwEnv: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const hwId of input.selectedHardware) {
|
||||||
|
const hw = hardwareMap.get(hwId);
|
||||||
|
if (!hw) continue;
|
||||||
|
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
|
||||||
|
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
|
||||||
|
hwDevices.push(...(hw.devices ?? []));
|
||||||
|
hwVolumes.push(...(hw.volumes ?? []));
|
||||||
|
Object.assign(hwEnv, hw.env ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
"services:",
|
||||||
|
" frigate:",
|
||||||
|
" container_name: frigate",
|
||||||
|
" privileged: true # This may not be necessary for all setups",
|
||||||
|
" restart: unless-stopped",
|
||||||
|
" stop_grace_period: 30s # Allow enough time to shut down the various services",
|
||||||
|
...buildImage(device),
|
||||||
|
` shm_size: "${input.shmSize || "512mb"}" # Update for your cameras based on SHM calculation`,
|
||||||
|
...buildRuntime(device),
|
||||||
|
...buildDeploy(device, input),
|
||||||
|
...buildExtraHosts(device),
|
||||||
|
...buildSecurityOpt(device),
|
||||||
|
...buildDevices(device, hwDevices),
|
||||||
|
...buildVolumes(device, hwVolumes, input.configPath, input.mediaPath),
|
||||||
|
...buildPorts(input.enabledPorts),
|
||||||
|
...buildEnvironment(device, hwEnv, input.rtspPassword, input.timezone),
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import { deviceMap, hardwareMap, portMap } from "../config";
|
||||||
|
import { generateDockerCompose } from "../generator";
|
||||||
|
import type { GeneratorInput } from "../generator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook that holds all form state and generates the Docker Compose output.
|
||||||
|
* Configuration is loaded synchronously from build-time generated .ts files.
|
||||||
|
*/
|
||||||
|
export function useConfigGenerator() {
|
||||||
|
const [deviceId, setDeviceId] = useState("stable");
|
||||||
|
|
||||||
|
const [hardwareEnabled, setHardwareEnabled] = useState<Record<string, boolean>>(() => {
|
||||||
|
const defaultDevice = deviceMap.get("stable");
|
||||||
|
const initial: Record<string, boolean> = {};
|
||||||
|
if (defaultDevice) {
|
||||||
|
for (const hwId of defaultDevice.autoHardware) {
|
||||||
|
initial[hwId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [portEnabled, setPortEnabled] = useState<Record<string, boolean>>(() => {
|
||||||
|
const initial: Record<string, boolean> = {};
|
||||||
|
for (const p of portMap.values()) {
|
||||||
|
initial[p.id] = p.defaultEnabled;
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [port5000Confirmed, setPort5000Confirmed] = useState(false);
|
||||||
|
const [nvidiaGpuCount, setNvidiaGpuCount] = useState("all");
|
||||||
|
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
|
||||||
|
const [configPath, setConfigPath] = useState("");
|
||||||
|
const [mediaPath, setMediaPath] = useState("");
|
||||||
|
const [rtspPassword, setRtspPassword] = useState("password");
|
||||||
|
const [timezone, setTimezone] = useState(
|
||||||
|
() => Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"
|
||||||
|
);
|
||||||
|
const [shmSize, setShmSize] = useState("512mb");
|
||||||
|
const [shmSizeError, setShmSizeError] = useState(false);
|
||||||
|
const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false);
|
||||||
|
const [configPathError, setConfigPathError] = useState(false);
|
||||||
|
const [mediaPathError, setMediaPathError] = useState(false);
|
||||||
|
|
||||||
|
const device = useMemo(() => deviceMap.get(deviceId)!, [deviceId]);
|
||||||
|
|
||||||
|
const selectDevice = useCallback((id: string) => {
|
||||||
|
const newDevice = deviceMap.get(id);
|
||||||
|
if (!newDevice) return;
|
||||||
|
setDeviceId(id);
|
||||||
|
setHardwareEnabled(() => {
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
for (const hwId of newDevice.autoHardware) {
|
||||||
|
next[hwId] = true;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setNvidiaGpuCount("all");
|
||||||
|
setNvidiaGpuDeviceId("");
|
||||||
|
setGpuDeviceIdError(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleHardware = useCallback((hwId: string) => {
|
||||||
|
setHardwareEnabled((prev) => ({ ...prev, [hwId]: !prev[hwId] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePort = useCallback((portId: string) => {
|
||||||
|
const port = portMap.get(portId);
|
||||||
|
if (port?.locked) return;
|
||||||
|
setPortEnabled((prev) => {
|
||||||
|
const next = { ...prev, [portId]: !prev[portId] };
|
||||||
|
if (portId === "5000" && !next[portId]) {
|
||||||
|
setPort5000Confirmed(false);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isHardwareDisabled = useCallback(
|
||||||
|
(hwId: string): boolean => {
|
||||||
|
const hw = hardwareMap.get(hwId);
|
||||||
|
if (!hw) return false;
|
||||||
|
return hw.disabledWhen?.includes(deviceId) ?? false;
|
||||||
|
},
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateShmSize = useCallback((value: string): boolean => {
|
||||||
|
if (!value) return true;
|
||||||
|
return /^\d+(\.\d+)?[bkmgBKMG]{1,2}$/.test(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validatePath = useCallback((value: string): boolean => {
|
||||||
|
if (!value) return true;
|
||||||
|
return /^[a-zA-Z0-9_\-/./]+$/.test(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShmSizeChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const filtered = value.replace(/[^0-9.bkmgBKMG]/g, "");
|
||||||
|
const valid = validateShmSize(filtered);
|
||||||
|
setShmSize(filtered);
|
||||||
|
setShmSizeError(!valid && filtered !== "");
|
||||||
|
},
|
||||||
|
[validateShmSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfigPathChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, "");
|
||||||
|
const valid = validatePath(filtered);
|
||||||
|
setConfigPath(filtered);
|
||||||
|
setConfigPathError(!valid && filtered !== "");
|
||||||
|
},
|
||||||
|
[validatePath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMediaPathChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, "");
|
||||||
|
const valid = validatePath(filtered);
|
||||||
|
setMediaPath(filtered);
|
||||||
|
setMediaPathError(!valid && filtered !== "");
|
||||||
|
},
|
||||||
|
[validatePath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNvidiaGpuCountChange = useCallback((value: string) => {
|
||||||
|
const lower = value.trim().toLowerCase();
|
||||||
|
if (lower === "all" || lower === "" || /^[0-9]+$/.test(lower)) {
|
||||||
|
setNvidiaGpuCount(lower || "all");
|
||||||
|
if (lower === "all") {
|
||||||
|
setNvidiaGpuDeviceId("");
|
||||||
|
setGpuDeviceIdError(false);
|
||||||
|
} else if (/^[0-9]+$/.test(lower)) {
|
||||||
|
setGpuDeviceIdError(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNvidiaGpuDeviceIdChange = useCallback((value: string) => {
|
||||||
|
setNvidiaGpuDeviceId(value.trim());
|
||||||
|
setGpuDeviceIdError(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enabledPortLines = useMemo(() => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [id, enabled] of Object.entries(portEnabled)) {
|
||||||
|
if (!enabled) continue;
|
||||||
|
if (id === "5000" && !port5000Confirmed) continue;
|
||||||
|
const p = portMap.get(id);
|
||||||
|
if (!p) continue;
|
||||||
|
const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : "";
|
||||||
|
const comment = p.description ? ` # ${p.description}` : "";
|
||||||
|
lines.push(` - "${p.host}:${p.container}${proto}"${comment}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}, [portEnabled, port5000Confirmed]);
|
||||||
|
|
||||||
|
const selectedHardwareIds = useMemo(() => {
|
||||||
|
return Object.entries(hardwareEnabled)
|
||||||
|
.filter(([id, enabled]) => {
|
||||||
|
if (!enabled) return false;
|
||||||
|
const hw = hardwareMap.get(id);
|
||||||
|
if (!hw) return false;
|
||||||
|
if (hw.disabledWhen?.includes(deviceId)) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(([id]) => id);
|
||||||
|
}, [hardwareEnabled, deviceId]);
|
||||||
|
|
||||||
|
const generatedYaml = useMemo(() => {
|
||||||
|
const input: GeneratorInput = {
|
||||||
|
device,
|
||||||
|
selectedHardware: selectedHardwareIds,
|
||||||
|
enabledPorts: enabledPortLines,
|
||||||
|
configPath: configPath || "/path/to/your/config",
|
||||||
|
mediaPath: mediaPath || "/path/to/your/storage",
|
||||||
|
rtspPassword: rtspPassword || "password",
|
||||||
|
timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC",
|
||||||
|
shmSize: shmSize || "512mb",
|
||||||
|
nvidiaGpuCount,
|
||||||
|
nvidiaGpuDeviceId,
|
||||||
|
};
|
||||||
|
return generateDockerCompose(input);
|
||||||
|
}, [
|
||||||
|
device, selectedHardwareIds, enabledPortLines,
|
||||||
|
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||||
|
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasAnyHardware = selectedHardwareIds.length > 0 || !!device?.devices?.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId, device, hardwareEnabled, portEnabled,
|
||||||
|
port5000Confirmed, nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||||
|
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||||
|
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
||||||
|
hasAnyHardware, generatedYaml,
|
||||||
|
selectDevice, toggleHardware, togglePort, setPort5000Confirmed,
|
||||||
|
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
||||||
|
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
||||||
|
setRtspPassword, setTimezone, isHardwareDisabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for a countdown timer (e.g. cooldown before confirming port 5000).
|
||||||
|
*/
|
||||||
|
export function useCooldown(initialSeconds: number) {
|
||||||
|
const [remaining, setRemaining] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
// Clear any existing timer
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
setRemaining(initialSeconds);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setRemaining((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}, [initialSeconds]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setRemaining(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { remaining, start, stop };
|
||||||
|
}
|
||||||
1
docs/src/components/DockerComposeGenerator/index.ts
Normal file
1
docs/src/components/DockerComposeGenerator/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./DockerComposeGenerator";
|
||||||
362
docs/src/components/DockerComposeGenerator/styles.module.css
Normal file
362
docs/src/components/DockerComposeGenerator/styles.module.css
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
/* ===================================================================
|
||||||
|
Docker Compose Generator — styles
|
||||||
|
Uses Docusaurus / Infima CSS variables for theme compatibility.
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
.generator {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--ifm-background-surface-color);
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-400);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: var(--ifm-global-shadow-lw);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .card {
|
||||||
|
background: var(--ifm-color-emphasis-100);
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Form sections --- */
|
||||||
|
|
||||||
|
.formSection {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--ifm-color-emphasis-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formSection:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formSection h4 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: var(--ifm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Form controls --- */
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
font-weight: var(--ifm-font-weight-semibold);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--ifm-color-emphasis-400);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--ifm-background-color);
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .input {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ifm-color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .input {
|
||||||
|
border-color: var(--ifm-color-emphasis-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputError {
|
||||||
|
border-color: #e74c3c;
|
||||||
|
animation: shake 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpText {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ifm-font-color-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpText a {
|
||||||
|
color: var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Device grid --- */
|
||||||
|
|
||||||
|
.deviceGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceCard {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid var(--ifm-color-emphasis-400);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--ifm-background-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .deviceCard {
|
||||||
|
border: 2px solid #d0d7de;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceCard:hover {
|
||||||
|
border-color: var(--ifm-color-primary);
|
||||||
|
background: var(--ifm-color-emphasis-100);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceCardActive {
|
||||||
|
border-color: var(--ifm-color-primary);
|
||||||
|
background: var(--ifm-color-primary-lightest);
|
||||||
|
box-shadow: 0 0 0 1px var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .deviceCardActive {
|
||||||
|
background: color-mix(in srgb, var(--ifm-color-primary) 12%, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .deviceCardActive {
|
||||||
|
background: color-mix(in srgb, var(--ifm-color-primary) 25%, #1b1b1b);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .deviceCardActive .deviceName {
|
||||||
|
color: var(--ifm-color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .deviceCardActive .deviceDesc {
|
||||||
|
color: var(--ifm-color-primary-light);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceIcon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
height: 40px;
|
||||||
|
width: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceIconSvg {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
height: 40px;
|
||||||
|
width: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: visible;
|
||||||
|
/* Allow iconStyle width/height to override */
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceIconSvg svg {
|
||||||
|
width: var(--svg-width, 100%);
|
||||||
|
height: var(--svg-height, 100%);
|
||||||
|
fill: var(--svg-fill, currentColor);
|
||||||
|
transform: var(--svg-transform, none);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceIconImage {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
height: 40px;
|
||||||
|
width: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceIconImage img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceName {
|
||||||
|
font-weight: var(--ifm-font-weight-semibold);
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceDesc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ifm-font-color-secondary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Checkbox grid --- */
|
||||||
|
|
||||||
|
.checkboxGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.checkboxGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardwareItem {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardwareDescription {
|
||||||
|
margin: 0.15rem 0 0.4rem 1.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ifm-font-color-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardwareDescription a {
|
||||||
|
color: var(--ifm-color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel:hover {
|
||||||
|
background: var(--ifm-color-emphasis-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel input[type="checkbox"] {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel span {
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxDisabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxDisabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxDisabled input[type="checkbox"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Form grid (side-by-side) --- */
|
||||||
|
|
||||||
|
.formGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.formGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGrid .formGroup {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Port section --- */
|
||||||
|
|
||||||
|
.portSection {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningBadge {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #e67e22;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- NVIDIA config --- */
|
||||||
|
|
||||||
|
.nvidiaConfig {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--ifm-background-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nvidiaConfig {
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-left: 3px solid var(--ifm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Result section --- */
|
||||||
|
|
||||||
|
.resultSection {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultHeader h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ifm-font-color-base);
|
||||||
|
}
|
||||||
@ -807,10 +807,15 @@ async def get_video_properties(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
async def probe_with_ffprobe(
|
async def probe_with_ffprobe(
|
||||||
url: str,
|
url: str,
|
||||||
|
rtsp_transport: Optional[str] = None,
|
||||||
) -> tuple[bool, int, int, Optional[str], float]:
|
) -> tuple[bool, int, int, Optional[str], float]:
|
||||||
"""Fallback using ffprobe: returns (valid, width, height, codec, duration)."""
|
"""Fallback using ffprobe: returns (valid, width, height, codec, duration)."""
|
||||||
cmd = [
|
cmd = [ffmpeg.ffprobe_path]
|
||||||
ffmpeg.ffprobe_path,
|
if rtsp_transport:
|
||||||
|
cmd += ["-rtsp_transport", rtsp_transport]
|
||||||
|
cmd += [
|
||||||
|
"-rw_timeout",
|
||||||
|
"5000000",
|
||||||
"-v",
|
"-v",
|
||||||
"quiet",
|
"quiet",
|
||||||
"-print_format",
|
"-print_format",
|
||||||
@ -872,12 +877,26 @@ async def get_video_properties(
|
|||||||
cap.release()
|
cap.release()
|
||||||
return valid, width, height, fourcc, duration
|
return valid, width, height, fourcc, duration
|
||||||
|
|
||||||
# try cv2 first
|
is_rtsp = url.startswith("rtsp://")
|
||||||
has_video, width, height, fourcc, duration = probe_with_cv2(url)
|
|
||||||
|
|
||||||
# fallback to ffprobe if needed
|
if is_rtsp:
|
||||||
if not has_video or (get_duration and duration < 0):
|
# skip cv2 for RTSP: its FFmpeg backend has a hardcoded ~30s internal
|
||||||
|
# timeout that cannot be shortened per-call, and ffprobe bounded by
|
||||||
|
# -rw_timeout handles RTSP probing reliably
|
||||||
has_video, width, height, fourcc, duration = await probe_with_ffprobe(url)
|
has_video, width, height, fourcc, duration = await probe_with_ffprobe(url)
|
||||||
|
else:
|
||||||
|
# try cv2 first for local files, HTTP, RTMP
|
||||||
|
has_video, width, height, fourcc, duration = probe_with_cv2(url)
|
||||||
|
|
||||||
|
# fallback to ffprobe if needed
|
||||||
|
if not has_video or (get_duration and duration < 0):
|
||||||
|
has_video, width, height, fourcc, duration = await probe_with_ffprobe(url)
|
||||||
|
|
||||||
|
# last resort for RTSP: try TCP transport, since default UDP may be blocked
|
||||||
|
if (not has_video or (get_duration and duration < 0)) and is_rtsp:
|
||||||
|
has_video, width, height, fourcc, duration = await probe_with_ffprobe(
|
||||||
|
url, rtsp_transport="tcp"
|
||||||
|
)
|
||||||
|
|
||||||
result: dict[str, Any] = {"has_valid_video": has_video}
|
result: dict[str, Any] = {"has_valid_video": has_video}
|
||||||
if has_video:
|
if has_video:
|
||||||
|
|||||||
@ -415,6 +415,7 @@
|
|||||||
"audioCodecGood": "Audio codec is {{codec}}.",
|
"audioCodecGood": "Audio codec is {{codec}}.",
|
||||||
"resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.",
|
"resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.",
|
||||||
"resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.",
|
"resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.",
|
||||||
|
"resolutionUnknown": "The resolution of this stream could not be probed. This will cause issues on startup. You should manually set the detect resolution in Settings or your config.",
|
||||||
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
|
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
|
||||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
||||||
|
|||||||
@ -218,7 +218,7 @@ export default function CameraReviewClassification({
|
|||||||
<Label
|
<Label
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-row items-center text-base",
|
"flex flex-row items-center text-base",
|
||||||
alertsZonesModified && "text-danger",
|
alertsZonesModified && "text-unsaved",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
||||||
@ -286,7 +286,7 @@ export default function CameraReviewClassification({
|
|||||||
<Label
|
<Label
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-row items-center text-base",
|
"flex flex-row items-center text-base",
|
||||||
detectionsZonesModified && "text-danger",
|
detectionsZonesModified && "text-unsaved",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
|
|||||||
@ -1012,7 +1012,7 @@ export function ConfigSection({
|
|||||||
>
|
>
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-danger">
|
<span className="text-sm text-unsaved">
|
||||||
{t("unsavedChanges", {
|
{t("unsavedChanges", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
defaultValue: "You have unsaved changes",
|
defaultValue: "You have unsaved changes",
|
||||||
@ -1299,7 +1299,7 @@ export function ConfigSection({
|
|||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
|
||||||
>
|
>
|
||||||
{t("button.modified", {
|
{t("button.modified", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
|
|||||||
@ -154,7 +154,7 @@ export function KnownPlatesField(props: FieldProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle
|
<CardTitle
|
||||||
className={cn("text-sm", isModified && "text-danger")}
|
className={cn("text-sm", isModified && "text-unsaved")}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export function ReplaceRulesField(props: FieldProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle
|
<CardTitle
|
||||||
className={cn("text-sm", isModified && "text-danger")}
|
className={cn("text-sm", isModified && "text-unsaved")}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@ -497,7 +497,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
isModified && "text-danger",
|
isModified && "text-unsaved",
|
||||||
hasFieldErrors && "text-destructive",
|
hasFieldErrors && "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -516,7 +516,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={cn("text-sm font-medium", isModified && "text-danger")}
|
className={cn("text-sm font-medium", isModified && "text-unsaved")}
|
||||||
>
|
>
|
||||||
{finalLabel}
|
{finalLabel}
|
||||||
{required && <span className="ml-1 text-destructive">*</span>}
|
{required && <span className="ml-1 text-destructive">*</span>}
|
||||||
@ -535,7 +535,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
isModified && "text-danger",
|
isModified && "text-unsaved",
|
||||||
hasFieldErrors && "text-destructive",
|
hasFieldErrors && "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -467,7 +467,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
<CardTitle
|
<CardTitle
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center text-sm",
|
"flex items-center text-sm",
|
||||||
hasModifiedDescendants && "text-danger",
|
hasModifiedDescendants && "text-unsaved",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{inferredLabel}
|
{inferredLabel}
|
||||||
|
|||||||
@ -607,23 +607,38 @@ function StreamIssues({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stream.roles.includes("detect") && stream.resolution) {
|
if (stream.roles.includes("detect") && stream.testResult) {
|
||||||
const [width, height] = stream.resolution.split("x").map(Number);
|
const probedResolution = stream.testResult.resolution;
|
||||||
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
|
let probedWidth = 0;
|
||||||
const minDimension = Math.min(width, height);
|
let probedHeight = 0;
|
||||||
const maxDimension = Math.max(width, height);
|
if (probedResolution) {
|
||||||
|
const [w, h] = probedResolution.split("x").map(Number);
|
||||||
|
if (!isNaN(w) && !isNaN(h)) {
|
||||||
|
probedWidth = w;
|
||||||
|
probedHeight = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probedWidth <= 0 || probedHeight <= 0) {
|
||||||
|
result.push({
|
||||||
|
type: "error",
|
||||||
|
message: t("cameraWizard.step4.issues.resolutionUnknown"),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const minDimension = Math.min(probedWidth, probedHeight);
|
||||||
|
const maxDimension = Math.max(probedWidth, probedHeight);
|
||||||
if (minDimension > 1080) {
|
if (minDimension > 1080) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||||
resolution: stream.resolution,
|
resolution: probedResolution,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else if (maxDimension < 640) {
|
} else if (maxDimension < 640) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||||
resolution: stream.resolution,
|
resolution: probedResolution,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1435,7 +1435,7 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showUnsavedDot && (
|
{showUnsavedDot && (
|
||||||
<span className="inline-block size-2 rounded-full bg-danger" />
|
<span className="inline-block size-2 rounded-full bg-unsaved" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1516,7 +1516,7 @@ export default function Settings() {
|
|||||||
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
|
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-danger">
|
<span className="text-sm text-unsaved">
|
||||||
{t("unsavedChanges", {
|
{t("unsavedChanges", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
defaultValue: "You have unsaved changes",
|
defaultValue: "You have unsaved changes",
|
||||||
|
|||||||
@ -79,11 +79,11 @@ const PROFILE_COLORS: ProfileColor[] = [
|
|||||||
bgMuted: "bg-green-400/20",
|
bgMuted: "bg-green-400/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bg: "bg-amber-400",
|
bg: "bg-fuchsia-500",
|
||||||
text: "text-amber-400",
|
text: "text-fuchsia-500",
|
||||||
dot: "bg-amber-400",
|
dot: "bg-fuchsia-500",
|
||||||
border: "border-amber-400",
|
border: "border-fuchsia-500",
|
||||||
bgMuted: "bg-amber-400/20",
|
bgMuted: "bg-fuchsia-500/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bg: "bg-slate-400",
|
bg: "bg-slate-400",
|
||||||
@ -93,11 +93,11 @@ const PROFILE_COLORS: ProfileColor[] = [
|
|||||||
bgMuted: "bg-slate-400/20",
|
bgMuted: "bg-slate-400/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bg: "bg-orange-300",
|
bg: "bg-stone-500",
|
||||||
text: "text-orange-300",
|
text: "text-stone-500",
|
||||||
dot: "bg-orange-300",
|
dot: "bg-stone-500",
|
||||||
border: "border-orange-300",
|
border: "border-stone-500",
|
||||||
bgMuted: "bg-orange-300/20",
|
bgMuted: "bg-stone-500/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bg: "bg-blue-300",
|
bg: "bg-blue-300",
|
||||||
|
|||||||
@ -380,7 +380,9 @@ export default function Go2RtcStreamsSettingsView({
|
|||||||
>
|
>
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-danger">{t("unsavedChanges")}</span>
|
<span className="text-sm text-unsaved">
|
||||||
|
{t("unsavedChanges")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||||
|
|||||||
@ -212,7 +212,7 @@ export function SingleSectionPage({
|
|||||||
{sectionStatus.hasChanges && (
|
{sectionStatus.hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
|
||||||
>
|
>
|
||||||
{t("button.modified", {
|
{t("button.modified", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
@ -250,7 +250,7 @@ export function SingleSectionPage({
|
|||||||
{sectionStatus.hasChanges && (
|
{sectionStatus.hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
|
||||||
>
|
>
|
||||||
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
|
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@ -65,6 +65,7 @@ module.exports = {
|
|||||||
ring: "hsl(var(--ring))",
|
ring: "hsl(var(--ring))",
|
||||||
danger: "#ef4444",
|
danger: "#ef4444",
|
||||||
success: "#22c55e",
|
success: "#22c55e",
|
||||||
|
unsaved: "#f59e0b",
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background))",
|
||||||
background_alt: "hsl(var(--background-alt))",
|
background_alt: "hsl(var(--background-alt))",
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: "hsl(var(--foreground))",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user