mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-01 19:17:41 +03:00
Merge 46a5eb4647 into edcf0b0d2c
This commit is contained in:
commit
1f9cfcd952
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
|
||||||
|
|||||||
@ -4,6 +4,9 @@ title: Installation
|
|||||||
---
|
---
|
||||||
|
|
||||||
import ShmCalculator from '@site/src/components/ShmCalculator'
|
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.
|
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.
|
Running through Docker with Docker Compose is the recommended install method.
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem value="domestic" label="Docker Compose Generator" default>
|
||||||
|
|
||||||
|
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
|
||||||
|
|
||||||
|
<DockerComposeGenerator/>
|
||||||
|
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="original" label="Example Docker Compose File">
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
frigate:
|
frigate:
|
||||||
@ -501,6 +514,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
FRIGATE_RTSP_PASSWORD: "password"
|
FRIGATE_RTSP_PASSWORD: "password"
|
||||||
```
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
**Docker CLI**
|
||||||
|
|
||||||
If you can't use Docker Compose, you can run the container with something similar to this:
|
If you can't use Docker Compose, you can run the container with something similar to this:
|
||||||
|
|
||||||
|
|||||||
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,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 (
|
||||||
|
<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,
|
||||||
|
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 (
|
||||||
|
<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}
|
||||||
|
onTogglePort={togglePort}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 Devices</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,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 (
|
||||||
|
<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"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={styles.input}
|
||||||
|
value={gpuCount}
|
||||||
|
placeholder="all"
|
||||||
|
onChange={(e) => onGpuCountChange(e.target.value.replace(/\D/g, ""))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showDeviceId && (
|
||||||
|
<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,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Admonition from "@theme/Admonition";
|
||||||
|
import { ports } from "../config";
|
||||||
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portEnabled: Record<string, boolean>;
|
||||||
|
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 (
|
||||||
|
<div className={styles.hardwareItem}>
|
||||||
|
<label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={onToggle}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{showWarning && (
|
||||||
|
<Admonition type={port.warningType || "warning"}>
|
||||||
|
{port.warningContent}
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortConfigSection({
|
||||||
|
portEnabled,
|
||||||
|
onTogglePort,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.formSection}>
|
||||||
|
<h4>Port Configuration</h4>
|
||||||
|
<div className={styles.checkboxGrid}>
|
||||||
|
{ports.map((port) => (
|
||||||
|
<PortItem
|
||||||
|
key={port.id}
|
||||||
|
port={port}
|
||||||
|
enabled={!!portEnabled[port.id]}
|
||||||
|
onToggle={() => onTogglePort(port.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
docs/src/components/DockerComposeGenerator/config/config.yaml
Normal file
297
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";
|
||||||
154
docs/src/components/DockerComposeGenerator/config/types.ts
Normal file
154
docs/src/components/DockerComposeGenerator/config/types.ts
Normal file
@ -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. "<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;
|
||||||
|
/** 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";
|
||||||
|
}
|
||||||
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 === "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,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<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 [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<string, boolean> = {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user