diff --git a/.gitignore b/.gitignore index c9db2929f..7c97a23a0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ core !/web/**/*.ts .idea/* .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 diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 5d228a609..72e53e3bc 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -4,6 +4,9 @@ title: Installation --- import ShmCalculator from '@site/src/components/ShmCalculator' +import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator' +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; 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. @@ -468,6 +471,16 @@ Finally, configure [hardware object detection](/configuration/object_detectors#a Running through Docker with Docker Compose is the recommended install method. + + + +Generate a Frigate Docker Compose configuration based on your hardware and requirements. + + + + + + ```yaml services: frigate: @@ -501,6 +514,10 @@ services: environment: FRIGATE_RTSP_PASSWORD: "password" ``` + + + +**Docker CLI** If you can't use Docker Compose, you can run the container with something similar to this: diff --git a/docs/package-lock.json b/docs/package-lock.json index ed766c1ab..222ae031a 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -14,9 +14,11 @@ "@docusaurus/theme-mermaid": "^3.7.0", "@inkeep/docusaurus": "^2.0.16", "@mdx-js/react": "^3.1.0", + "@types/js-yaml": "^4.0.9", "clsx": "^2.1.1", "docusaurus-plugin-openapi-docs": "^4.5.1", "docusaurus-theme-openapi-docs": "^4.5.1", + "js-yaml": "^4.1.1", "prism-react-renderer": "^2.4.1", "raw-loader": "^4.0.2", "react": "^18.3.1", @@ -5747,6 +5749,11 @@ "@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": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -12883,7 +12890,7 @@ }, "node_modules/js-yaml": { "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==", "license": "MIT", "dependencies": { diff --git a/docs/package.json b/docs/package.json index 0ff76c473..e57d7a154 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,9 +3,10 @@ "version": "0.0.0", "private": true, "scripts": { + "build:config": "node scripts/build-config.mjs", "docusaurus": "docusaurus", - "start": "npm run regen-docs && docusaurus start --host 0.0.0.0", - "build": "npm run regen-docs && docusaurus build", + "start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0", + "build": "npm run build:config && npm run regen-docs && docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -23,9 +24,11 @@ "@docusaurus/theme-mermaid": "^3.7.0", "@inkeep/docusaurus": "^2.0.16", "@mdx-js/react": "^3.1.0", + "@types/js-yaml": "^4.0.9", "clsx": "^2.1.1", "docusaurus-plugin-openapi-docs": "^4.5.1", "docusaurus-theme-openapi-docs": "^4.5.1", + "js-yaml": "^4.1.1", "prism-react-renderer": "^2.4.1", "raw-loader": "^4.0.2", "react": "^18.3.1", diff --git a/docs/scripts/build-config.mjs b/docs/scripts/build-config.mjs new file mode 100644 index 000000000..78926bed5 --- /dev/null +++ b/docs/scripts/build-config.mjs @@ -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 = 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!"); diff --git a/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx b/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx new file mode 100644 index 000000000..b8a8a8fc8 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx @@ -0,0 +1,108 @@ +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 ( + + {match[1]} + + ); + } + return {part}; + }); +} + +export default function DockerComposeGenerator() { + const { + deviceId, device, hardwareEnabled, + portEnabled, + nvidiaGpuCount, nvidiaGpuDeviceId, + configPath, mediaPath, rtspPassword, timezone, shmSize, + shmSizeError, gpuDeviceIdError, configPathError, mediaPathError, + hasAnyHardware, generatedYaml, + selectDevice, toggleHardware, togglePort, + handleShmSizeChange, handleConfigPathChange, handleMediaPathChange, + handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange, + setRtspPassword, setTimezone, isHardwareDisabled, + } = useConfigGenerator(); + + return ( +
+
+ + + {device.helpText && ( + + {renderHelpText(device.helpText)} + + )} + + {device.needsNvidiaConfig && ( + + )} + + + + + + + + + + +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx b/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx new file mode 100644 index 000000000..ddad16050 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx @@ -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 " 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 . + */ +function toCssVars(style: React.CSSProperties | undefined, prefix: string): React.CSSProperties { + if (!style) return {}; + const vars: Record = {}; + 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 ( +
+ ); + } + + if (iconType === "image") { + // When iconStyle contains background-* properties, render as background-image + // on the container div instead of an tag, enabling background-size/position control. + if (hasBackgroundProps(iconStyle)) { + return ( +
+ ); + } + return ( +
+ {device.name} +
+ ); + } + + return ( +
+ {iconStr} +
+ ); +} + +function DeviceCard({ + device, + active, + onClick, +}: { + device: DeviceConfig; + active: boolean; + onClick: () => void; +}) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") onClick(); + }} + > + +
{device.name}
+
{device.description}
+
+ ); +} + +export default function DeviceSelector({ selectedId, onSelect }: Props) { + return ( +
+

Device Type

+
+ {devices.map((d) => ( + onSelect(d.id)} + /> + ))} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx b/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx new file mode 100644 index 000000000..f170637aa --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx @@ -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 ( +
+
+

Generated Configuration

+ +
+ + {!configPath && ( + +

You haven't specified a config file directory. You may want to modify the default path.

+
+ )} + {!mediaPath && ( + +

You haven't specified a recording storage directory. You may want to modify the default path.

+
+ )} + {deviceId === "stable" && !hasAnyHardware && ( + +

You haven't selected any hardware acceleration. Please check if you have supported hardware available.

+
+ )} + + + {yaml} + +
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx b/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx new file mode 100644 index 000000000..9c261ed41 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx @@ -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; + 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 {match[1]}; + } + return {part}; + }); +} + +function HardwareCheckbox({ + hw, disabled, checked, onToggle, +}: { + hw: HardwareOption; disabled: boolean; checked: boolean; onToggle: () => void; +}) { + return ( +
+ + {checked && hw.description && ( +
{renderDescription(hw.description)}
+ )} +
+ ); +} + +export default function HardwareOptions({ deviceId, hardwareEnabled, onToggle, isDisabled }: Props) { + return ( +
+

Generic Hardware Devices

+ {deviceId !== "stable" && ( +

+ Some options have been auto-configured based on your device type. +

+ )} +
+ {hardwareOptions.map((hw) => { + const disabled = isDisabled(hw.id); + const checked = disabled ? false : !!hardwareEnabled[hw.id]; + return ( + onToggle(hw.id)} /> + ); + })} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx b/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx new file mode 100644 index 000000000..9c9be5e6a --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx @@ -0,0 +1,64 @@ +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) { + const showDeviceId = gpuCount !== ""; + + return ( +
+
+ + onGpuCountChange(e.target.value.replace(/\D/g, ""))} + /> +
+ {showDeviceId && ( +
+ + onGpuDeviceIdChange(e.target.value)} + /> + {gpuDeviceIdError ? ( +

+ ⚠️ GPU device IDs are required when GPU count is a number +

+ ) : ( +

+ Single GPU: 0  |  Multiple GPUs: 0,1,2 +

+ )} +
+ )} +
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx b/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx new file mode 100644 index 000000000..3a14a2c16 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx @@ -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 ( +
+

Other Options

+
+
+ + onRtspPasswordChange(e.target.value)} + /> +

+ Used as{" "} + {"{FRIGATE_RTSP_PASSWORD}"}{" "} + in the config file to reference camera stream passwords. This is NOT + the Frigate login password. +

+
+
+ + onTimezoneChange(e.target.value)} + /> +
+
+ + onShmSizeChange(e.target.value)} + /> + {shmSizeError ? ( +

+ ⚠️ Invalid format. Use a number followed by a unit (e.g. 512mb, 1gb) +

+ ) : ( +

+ See{" "} + + calculating required SHM size + {" "} + for the correct value. +

+ )} +
+
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx b/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx new file mode 100644 index 000000000..c4e5acf71 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import Admonition from "@theme/Admonition"; +import { ports } from "../config"; +import styles from "../styles.module.css"; + +interface Props { + portEnabled: Record; + onTogglePort: (portId: string) => void; +} + +function PortItem({ + port, + enabled, + onToggle, +}: { + port: typeof ports[number]; + enabled: boolean; + onToggle: () => void; +}) { + const showWarning = port.warningContent && ( + port.warningWhen === "checked" ? enabled : + port.warningWhen === "unchecked" ? !enabled : enabled + ); + + return ( +
+ + {port.description && ( +
{port.description}
+ )} + {showWarning && ( + + {port.warningContent} + + )} +
+ ); +} + +export default function PortConfigSection({ + portEnabled, + onTogglePort, +}: Props) { + return ( +
+

Port Configuration

+
+ {ports.map((port) => ( + onTogglePort(port.id)} + /> + ))} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx b/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx new file mode 100644 index 000000000..89581bf11 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx @@ -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 ( +
+

Storage Paths

+
+
+ + onConfigPathChange(e.target.value)} + /> + {configPathError && ( +

+ ⚠️ Path contains invalid characters. Only letters, numbers, + underscores, hyphens, slashes, and dots are allowed. +

+ )} +
+
+ + onMediaPathChange(e.target.value)} + /> + {mediaPathError && ( +

+ ⚠️ Path contains invalid characters. Only letters, numbers, + underscores, hyphens, slashes, and dots are allowed. +

+ )} +
+
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/config/config.yaml b/docs/src/components/DockerComposeGenerator/config/config.yaml new file mode 100644 index 000000000..22734a1bf --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/config.yaml @@ -0,0 +1,297 @@ +# Unified configuration for Docker Compose Generator +# This file defines all devices, hardware options, and ports for Frigate Docker Compose generation + +devices: + - id: "stable" + name: "Standard x86_64" + description: "Generic PC / server" + icon: "💻" + imageTag: "stable" + autoHardware: [] + + - id: "intel" + name: "Intel Device" + description: "Intel GPU / NPU" + icon: '' + imageTag: "stable" + autoHardware: + - "gpu" + - "intelNpu" + helpText: "Intel Device automatically configures /dev/dri and /dev/accel device mappings." + helpType: "info" + + - id: "stable-tensorrt" + name: "NVIDIA GPU" + description: "NVIDIA acceleration" + icon: '' + svgStyle: + width: 50px + height: 50px + iconStyle: + padding-bottom: 15px + imageTag: "stable-tensorrt" + autoHardware: [] + helpText: "Requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker) to be installed. GPU deploy resources are configured automatically." + helpType: "warning" + needsNvidiaConfig: true + + - id: "stable-tensorrt-jp6" + name: "NVIDIA Jetson" + description: "Jetson development board" + icon: '' + svgStyle: + width: 50px + height: 50px + iconStyle: + padding-bottom: 15px + imageTag: "stable-tensorrt-jp6" + autoHardware: [] + helpText: "NVIDIA Jetson devices automatically configure runtime: nvidia." + helpType: "info" + runtime: "nvidia" + + - id: "stable-rocm" + name: "AMD GPU" + description: "ROCm acceleration" + icon: "https://www.amd.com/content/dam/code/images/header/amd-header-logo.svg" + iconStyle: + filter: invert(1) + background-repeat: no-repeat + background-position: right center + background-size: 338% 90% + iconDark: "https://www.amd.com/content/dam/code/images/header/amd-header-logo.svg" + iconDarkStyle: + filter: invert(0) + background-repeat: no-repeat + background-position: right center + background-size: 338% 90% + imageTag: "stable-rocm" + autoHardware: + - "gpu" + helpText: "AMD GPU automatically configures LIBVA_DRIVER_NAME environment variable and /dev/dri device mapping." + helpType: "info" + env: + LIBVA_DRIVER_NAME: "radeonsi" + + - id: "apple-silicon" + name: "Apple Silicon" + description: "Mac M-series processor" + icon: '' + svgStyle: + width: 90px + height: 90px + svgDarkStyle: + width: 90px + height: 90px + fill: white + imageTag: "stable" + imageTagSuffix: "-standard-arm64" + autoHardware: [] + helpText: "Apple Silicon (M-series) requires an [external detector](/configuration/object_detectors#apple-silicon-detector) running on the host." + helpType: "warning" + extraHosts: + - "host.docker.internal:host-gateway" + + - id: "raspberry-pi" + name: "Raspberry Pi" + description: "ARM device" + icon: '' + svgStyle: + width: 40px + height: 40px + transform: translateX(-3px) + imageTag: "stable" + imageTagSuffix: "-standard-arm64" + autoHardware: + - "video11" + helpText: "Raspberry Pi automatically configures the video11 device (RPi 4) and uses the arm64 image." + helpType: "info" + + - id: "stable-rk" + name: "Rockchip" + description: "Rockchip SoC board" + icon: "https://www.rock-chips.com/favicon.ico" + imageTag: "stable-rk" + autoHardware: + - "gpu" + helpText: "Rockchip devices automatically configure /dev/dri device mapping." + helpType: "info" + devices: + - host: "/dev/dma_heap" + comment: "Rockchip DMA heap" + - host: "/dev/rga" + comment: "Rockchip RGA" + - host: "/dev/mpp_service" + comment: "Rockchip MPP service" + volumes: + - host: "/sys/" + container: "/sys/" + readOnly: true + comment: "Rockchip system info" + securityOpt: + - "apparmor=unconfined" + - "systempaths=unconfined" + + - id: "stable-synaptics" + name: "Synaptics" + description: "Synaptics NPU" + icon: "🔷" + imageTag: "stable-synaptics" + autoHardware: [] + helpText: "Synaptics devices automatically configure /dev/synap and video devices." + helpType: "info" + devices: + - host: "/dev/synap" + comment: "Synaptics NPU" + - host: "/dev/video0" + comment: "Video device 0" + - host: "/dev/video1" + comment: "Video device 1" + +hardware: + - id: "usbCoral" + label: "USB Coral (TPU)" + description: "Enable this if you have a Google Coral USB TPU. Other Coral versions require different device paths." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/bus/usb" + container: "/dev/bus/usb" + comment: "USB Coral — modify for other versions" + + - id: "pcieCoral" + label: "PCIe Coral (TPU)" + description: "Enable this if you have a Google Coral PCIe/M.2 TPU. You also need to [install the driver](https://github.com/jnicolson/gasket-builder)." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/apex_0" + container: "/dev/apex_0" + comment: "PCIe Coral — follow driver instructions at https://github.com/jnicolson/gasket-builder" + + - id: "gpu" + label: "GPU Acceleration (/dev/dri)" + description: "Pass through /dev/dri for GPU hardware acceleration (Intel/AMD)." + disabledWhen: + - "stable-tensorrt-jp6" + - "apple-silicon" + devices: + - host: "/dev/dri" + container: "/dev/dri" + comment: "Intel/AMD GPU hardware acceleration" + + - id: "intelNpu" + label: "Intel NPU (/dev/accel)" + description: "Pass through /dev/accel for Intel NPU acceleration." + disabledWhen: + - "stable-tensorrt-jp6" + - "apple-silicon" + - "stable-rocm" + - "stable-rk" + - "stable-synaptics" + devices: + - host: "/dev/accel" + container: "/dev/accel" + comment: "Intel NPU" + + - id: "hailo" + label: "Hailo NPU (/dev/hailo0)" + description: "Pass through /dev/hailo0 for Hailo-8 / Hailo-8L NPU acceleration." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/hailo0" + comment: "Hailo NPU" + + - id: "memryx" + label: "MemryX MX3 (/dev/memx0)" + description: "Pass through /dev/memx0 for MemryX MX3 NPU acceleration." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/memx0" + comment: "MemryX MX3 NPU" + volumes: + - host: "/run/mxa_manager" + container: "/run/mxa_manager" + comment: "MemryX manager" + + - id: "axera" + label: "AXERA Accelerator" + description: "Pass through AXERA accelerator devices. Requires the [AXCL driver](#axera) to be installed first." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/axcl_host" + comment: "AXERA accelerator device" + - host: "/dev/ax_mmb_dev" + comment: "AXERA MMB device" + - host: "/dev/msg_userdev" + comment: "AXERA message device" + volumes: + - host: "/usr/bin/axcl" + container: "/usr/bin/axcl" + comment: "AXERA binaries" + - host: "/usr/lib/axcl" + container: "/usr/lib/axcl" + comment: "AXERA libraries" + + - id: "video11" + label: "Raspberry Pi video11" + description: "Pass through /dev/video11 for Raspberry Pi 4B hardware acceleration." + disabledWhen: + - "stable-tensorrt" + - "stable-tensorrt-jp6" + - "stable-rocm" + - "stable-rk" + - "stable-synaptics" + - "intel" + - "apple-silicon" + - "stable" + devices: + - host: "/dev/video11" + container: "/dev/video11" + comment: "Raspberry Pi 4B" + +ports: + - id: "8971" + host: 8971 + container: 8971 + protocol: "tcp" + description: "Authenticated UI and API access (default HTTPS)" + defaultEnabled: true + warningContent: "This is the access port for Frigate. Closing it means you will no longer be able to access the instance." + warningWhen: "unchecked" + + - id: "8554" + host: 8554 + container: 8554 + protocol: "tcp" + description: "RTSP feeds" + defaultEnabled: true + + - id: "8555-tcp" + host: 8555 + container: 8555 + protocol: "tcp" + description: "WebRTC over TCP" + defaultEnabled: true + + - id: "8555-udp" + host: 8555 + container: 8555 + protocol: "udp" + description: "WebRTC over UDP" + defaultEnabled: true + + - id: "1984" + host: 1984 + container: 1984 + protocol: "tcp" + description: "Go2RTC Web UIport" + defaultEnabled: false diff --git a/docs/src/components/DockerComposeGenerator/config/index.ts b/docs/src/components/DockerComposeGenerator/config/index.ts new file mode 100644 index 000000000..5acaba9f1 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/index.ts @@ -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"; diff --git a/docs/src/components/DockerComposeGenerator/config/types.ts b/docs/src/components/DockerComposeGenerator/config/types.ts new file mode 100644 index 000000000..87bcb608d --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/types.ts @@ -0,0 +1,154 @@ +/** + * 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. "...") + */ + 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 `` tag and styles apply to it. + * - For emoji/SVG icons: styles apply to the container div. + */ + iconStyle?: Record; + /** + * Additional CSS properties applied directly to the inner `` 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; + /** + * 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; + /** + * SVG-specific styles for dark mode. Same as `svgStyle` but applied + * when dark mode is active. Merged over `svgStyle` in dark mode. + */ + svgDarkStyle?: Record; + /** 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; + /** 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; +} + +/** 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; + /** Admonition type for the warning */ + warningType?: "warning" | "danger"; + /** Warning content (markdown) */ + warningContent?: string; + /** When to show the warning: when the port is checked or unchecked */ + warningWhen?: "checked" | "unchecked"; +} diff --git a/docs/src/components/DockerComposeGenerator/generator/index.ts b/docs/src/components/DockerComposeGenerator/generator/index.ts new file mode 100644 index 000000000..589d9b0b6 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/generator/index.ts @@ -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, + rtspPassword: string, + timezone: string +): string[] { + const allEnv: Record = { + ...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 === "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 = {}; + + 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"); +} diff --git a/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts b/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts new file mode 100644 index 000000000..394790b27 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts @@ -0,0 +1,197 @@ +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>(() => { + const defaultDevice = deviceMap.get("stable"); + const initial: Record = {}; + if (defaultDevice) { + for (const hwId of defaultDevice.autoHardware) { + initial[hwId] = true; + } + } + return initial; + }); + + const [portEnabled, setPortEnabled] = useState>(() => { + const initial: Record = {}; + for (const p of portMap.values()) { + initial[p.id] = p.defaultEnabled; + } + return initial; + }); + + const [nvidiaGpuCount, setNvidiaGpuCount] = useState(""); + 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 = {}; + for (const hwId of newDevice.autoHardware) { + next[hwId] = true; + } + return next; + }); + setNvidiaGpuCount(""); + 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) => ({ ...prev, [portId]: !prev[portId] })); + }, []); + + 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) => { + // Only allow digits + setNvidiaGpuCount(value); + if (value === "") { + setNvidiaGpuDeviceId(""); + setGpuDeviceIdError(false); + } else { + 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; + 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]); + + 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, + nvidiaGpuCount, nvidiaGpuDeviceId, + configPath, mediaPath, rtspPassword, timezone, shmSize, + shmSizeError, gpuDeviceIdError, configPathError, mediaPathError, + hasAnyHardware, generatedYaml, + selectDevice, toggleHardware, togglePort, + handleShmSizeChange, handleConfigPathChange, handleMediaPathChange, + handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange, + setRtspPassword, setTimezone, isHardwareDisabled, + }; +} diff --git a/docs/src/components/DockerComposeGenerator/index.ts b/docs/src/components/DockerComposeGenerator/index.ts new file mode 100644 index 000000000..76dd58756 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/index.ts @@ -0,0 +1 @@ +export { default } from "./DockerComposeGenerator"; diff --git a/docs/src/components/DockerComposeGenerator/styles.module.css b/docs/src/components/DockerComposeGenerator/styles.module.css new file mode 100644 index 000000000..62ecb1b6f --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/styles.module.css @@ -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); +}