add onboarding wizard for new users

This commit is contained in:
Josh Hawkins
2026-08-22 11:51:08 -05:00
parent 01bb9f3f37
commit bb5e9264b0
19 changed files with 1812 additions and 0 deletions
+19
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
from frigate.api.auth import require_role from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.detectors.hardware import DetectionHardware, hardware_prober from frigate.detectors.hardware import DetectionHardware, hardware_prober
from frigate.util.hwaccel import HwaccelRecommendation, recommend_hwaccel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,3 +29,21 @@ def probe_hardware(refresh: bool = False) -> list[DetectionHardware]:
Every kind of detection hardware that was found Every kind of detection hardware that was found
""" """
return hardware_prober.probe(refresh=refresh) return hardware_prober.probe(refresh=refresh)
@router.get(
"/hardware/hwaccel",
response_model=HwaccelRecommendation,
dependencies=[Depends(require_role(["admin"]))],
)
def hwaccel_recommendation(detector: str | None = None) -> HwaccelRecommendation:
"""Get the ffmpeg hwaccel preset recommended for this system.
Args:
detector: Hardware key of the detection hardware in use, which biases
the recommendation toward that hardware's GPU
Returns:
The recommended preset, empty when no hardware acceleration fits
"""
return HwaccelRecommendation(preset=recommend_hwaccel(detector))
+7
View File
@@ -17,6 +17,7 @@ from pydantic import (
) )
from ruamel.yaml import YAML from ruamel.yaml import YAML
from frigate.config.onboarding import OnboardingConfig
from frigate.const import REGEX_JSON from frigate.const import REGEX_JSON
from frigate.detectors import ModelConfig from frigate.detectors import ModelConfig
from frigate.detectors.detector_config import SceneEnum from frigate.detectors.detector_config import SceneEnum
@@ -636,6 +637,12 @@ class FrigateConfig(FrigateBaseModel):
description="Named profile definitions with friendly names. Camera profiles must reference names defined here.", description="Named profile definitions with friendly names. Camera profiles must reference names defined here.",
) )
onboarding: OnboardingConfig = Field(
default_factory=OnboardingConfig,
title="Onboarding",
description="First-time setup wizard state. Tracks whether the setup wizard has been completed or dismissed.",
)
active_profile: str | None = Field( active_profile: str | None = Field(
default=None, default=None,
title="Active profile", title="Active profile",
+13
View File
@@ -0,0 +1,13 @@
"""Onboarding configuration for first-time setup wizard."""
from pydantic import Field
from frigate.config.base import FrigateBaseModel
class OnboardingConfig(FrigateBaseModel):
setup_complete: bool = Field(
default=False,
title="Setup complete",
description="Set to true after the first-time setup wizard is completed or dismissed.",
)
+140
View File
@@ -0,0 +1,140 @@
"""Tests for the ffmpeg hwaccel preset recommendation."""
import os
import tempfile
import unittest
from unittest.mock import patch
from frigate.detectors.hardware import DetectionHardware
from frigate.util import hwaccel
def found(key: str) -> DetectionHardware:
"""A probe result carrying only the fields the recommendation reads."""
return DetectionHardware(
key=key,
detector=key.partition(":")[0],
name=key,
units=[],
count=0,
unlimited=True,
)
class HwaccelRecommendationTestCase(unittest.TestCase):
"""Points every read at an empty fixture tree, so nothing is found by default."""
def setUp(self):
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
self.proc_root = os.path.join(self.root.name, "proc")
os.makedirs(self.proc_root)
patcher = patch.object(hwaccel, "PROC_ROOT", self.proc_root)
patcher.start()
self.addCleanup(patcher.stop)
drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={})
self.drm = drm.start()
self.addCleanup(drm.stop)
def recommend(self, keys=(), detector_key=None) -> str:
"""Recommend with the prober reporting exactly these hardware keys."""
with patch.object(
hwaccel.hardware_prober,
"probe",
return_value=[found(key) for key in keys],
):
return hwaccel.recommend_hwaccel(detector_key)
def write_cpuinfo(self, model_name: str) -> None:
with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f:
f.write(f"processor\t: 0\nmodel name\t: {model_name}\n")
class TestPriority(HwaccelRecommendationTestCase):
def test_nothing_found_recommends_nothing(self):
self.assertEqual(self.recommend(), "")
def test_nvidia_wins_over_intel(self):
self.assertEqual(
self.recommend(["onnx:nvidia", "openvino:GPU"]), "preset-nvidia"
)
def test_a_jetson_uses_its_own_preset(self):
self.assertEqual(self.recommend(["tensorrt"]), "preset-jetson-h264")
def test_a_rockchip_uses_rkmpp(self):
self.assertEqual(self.recommend(["rknn"]), "preset-rkmpp")
def test_an_amd_gpu_uses_vaapi(self):
self.assertEqual(self.recommend(["onnx:amd"]), "preset-vaapi")
class TestDetectorBias(HwaccelRecommendationTestCase):
def test_a_chosen_intel_gpu_beats_a_present_nvidia(self):
self.assertEqual(
self.recommend(["onnx:nvidia", "openvino:GPU"], "openvino:GPU"),
"preset-vaapi",
)
def test_a_chosen_npu_decodes_through_the_igpu(self):
self.assertEqual(
self.recommend(["openvino:NPU", "openvino:GPU"], "openvino:NPU"),
"preset-vaapi",
)
def test_an_npu_without_an_igpu_falls_through(self):
self.assertEqual(self.recommend(["openvino:NPU"], "openvino:NPU"), "")
def test_a_cpu_choice_still_recommends_the_present_gpu(self):
self.assertEqual(self.recommend(["cpu", "openvino:GPU"], "cpu"), "preset-vaapi")
def test_a_detector_key_for_absent_hardware_is_ignored(self):
self.assertEqual(
self.recommend(["openvino:GPU"], "onnx:nvidia"), "preset-vaapi"
)
class TestIntelGeneration(HwaccelRecommendationTestCase):
def test_the_xe_driver_uses_qsv(self):
self.drm.return_value = {"0000:00:02.0": "xe"}
self.assertEqual(self.recommend(["openvino:GPU"]), "preset-intel-qsv-h264")
def test_gen13_uses_qsv(self):
self.write_cpuinfo("13th Gen Intel(R) Core(TM) i5-13500")
self.assertEqual(self.recommend(["openvino:GPU"]), "preset-intel-qsv-h264")
def test_gen12_uses_vaapi(self):
self.write_cpuinfo("12th Gen Intel(R) Core(TM) i5-12400")
self.assertEqual(self.recommend(["openvino:GPU"]), "preset-vaapi")
def test_an_older_model_string_uses_vaapi(self):
self.write_cpuinfo("Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz")
self.assertEqual(self.recommend(["openvino:GPU"]), "preset-vaapi")
def test_missing_cpuinfo_uses_vaapi(self):
self.assertEqual(self.recommend(["openvino:GPU"]), "preset-vaapi")
def test_a_core_ultra_uses_qsv(self):
self.write_cpuinfo("Intel(R) Core(TM) Ultra 7 155H")
self.assertEqual(self.recommend(["openvino:GPU"]), "preset-intel-qsv-h264")
class TestRaspberryPi(HwaccelRecommendationTestCase):
def write_device_tree(self) -> None:
os.makedirs(os.path.join(self.proc_root, "device-tree"))
with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f:
f.write("raspberrypi,5-model-b\x00brcm,bcm2712\x00")
def test_a_pi_uses_v4l2(self):
self.write_device_tree()
self.assertEqual(self.recommend(), "preset-rpi-64-h264")
def test_a_gpu_wins_over_the_pi_fallback(self):
self.write_device_tree()
self.assertEqual(self.recommend(["onnx:nvidia"]), "preset-nvidia")
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -733,6 +733,14 @@ def migrate_019_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
new_config["cameras"][name] = camera_config new_config["cameras"][name] = camera_config
# Set setup_complete to true for existing configs that already have cameras,
# so the setup wizard does not appear for users upgrading from older versions.
cameras = new_config.get("cameras", {})
if len(cameras) > 0:
onboarding = new_config.get("onboarding", {})
onboarding["setup_complete"] = True
new_config["onboarding"] = onboarding
new_config["version"] = "0.19-0" new_config["version"] = "0.19-0"
return new_config return new_config
+123
View File
@@ -0,0 +1,123 @@
"""Recommendation of an ffmpeg hwaccel preset from the hardware on the system.
Every check is a filesystem read, like the detection hardware probes, so this
is cheap enough to serve from the API process.
"""
import logging
import re
from pydantic import BaseModel, Field
from frigate.const import (
FFMPEG_HWACCEL_NVIDIA,
FFMPEG_HWACCEL_RKMPP,
FFMPEG_HWACCEL_VAAPI,
)
from frigate.detectors.hardware import hardware_prober
from frigate.util.services import enumerate_drm_devices
logger = logging.getLogger(__name__)
# root the /proc reads use, so tests can point them at a fixture tree
PROC_ROOT = "/proc"
PRESET_JETSON = "preset-jetson-h264"
PRESET_INTEL_QSV = "preset-intel-qsv-h264"
PRESET_RPI = "preset-rpi-64-h264"
# marketing name of a newer Intel CPU, e.g. "13th Gen Intel(R) Core(TM) i5-13500"
INTEL_GEN_PATTERN = re.compile(r"(\d+)th Gen")
# per the hwaccel docs, gen13+ and Arc use qsv while older is safest on vaapi
INTEL_QSV_MIN_GEN = 13
# detection hardware whose GPU also decodes video, in recommendation priority
# order. None marks Intel, whose preset depends on the platform generation.
DECODE_PRESETS: dict[str, str | None] = {
"onnx:nvidia": FFMPEG_HWACCEL_NVIDIA,
"tensorrt": PRESET_JETSON,
"rknn": FFMPEG_HWACCEL_RKMPP,
"openvino:GPU": None,
"onnx:amd": FFMPEG_HWACCEL_VAAPI,
}
class HwaccelRecommendation(BaseModel):
"""The hwaccel preset recommended for this system."""
preset: str = Field(
title="Recommended preset",
description="The ffmpeg hwaccel preset that fits this system's hardware, or an empty string when none does.",
)
def _read(path: str) -> str | None:
"""Read a small file, returning None if it cannot be read."""
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def _intel_preset() -> str:
"""Pick vaapi or qsv for an Intel GPU based on the platform generation."""
# the xe driver only binds to the newest platforms (Arc and later iGPUs)
if "xe" in enumerate_drm_devices().values():
return PRESET_INTEL_QSV
cpuinfo = _read(f"{PROC_ROOT}/cpuinfo") or ""
for line in cpuinfo.splitlines():
if line.startswith("model name"):
match = INTEL_GEN_PATTERN.search(line)
if match and int(match.group(1)) >= INTEL_QSV_MIN_GEN:
return PRESET_INTEL_QSV
# Core Ultra (Meteor Lake and later) dropped the "Nth Gen" prefix
# but sits past the qsv cutoff
if "Core(TM) Ultra" in line:
return PRESET_INTEL_QSV
break
return FFMPEG_HWACCEL_VAAPI
def _is_raspberry_pi() -> bool:
compatible = _read(f"{PROC_ROOT}/device-tree/compatible") or ""
return "raspberrypi" in compatible
def recommend_hwaccel(detector_key: str | None = None) -> str:
"""Recommend an ffmpeg hwaccel preset for this system.
Args:
detector_key: Hardware key of the detection hardware in use, which
biases the recommendation toward that hardware's GPU
Returns:
The name of the preset that fits, or an empty string when none does
"""
present = {found.key for found in hardware_prober.probe()}
candidates: list[str] = []
if detector_key in DECODE_PRESETS and detector_key in present:
candidates.append(detector_key)
elif detector_key == "openvino:NPU" and "openvino:GPU" in present:
# an Intel NPU decodes through the iGPU next to it
candidates.append("openvino:GPU")
candidates.extend(key for key in DECODE_PRESETS if key in present)
if candidates:
preset = DECODE_PRESETS[candidates[0]]
return preset if preset is not None else _intel_preset()
if _is_raspberry_pi():
return PRESET_RPI
return ""
+6
View File
@@ -43,6 +43,7 @@ export interface ApiMockOverrides {
configRaw?: string; configRaw?: string;
configSchema?: Record<string, unknown>; configSchema?: Record<string, unknown>;
hardware?: unknown[]; hardware?: unknown[];
hwaccel?: { preset: string };
} }
export class ApiMocker { export class ApiMocker {
@@ -185,6 +186,11 @@ export class ApiMocker {
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }), route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
); );
// Hwaccel preset recommendation
await this.page.route("**/api/hardware/hwaccel**", (route) =>
route.fulfill({ json: overrides?.hwaccel ?? { preset: "" } }),
);
// Go2RTC streams // Go2RTC streams
await this.page.route("**/api/go2rtc/streams**", (route) => await this.page.route("**/api/go2rtc/streams**", (route) =>
route.fulfill({ json: {} }), route.fulfill({ json: {} }),
+204
View File
@@ -0,0 +1,204 @@
/**
* Setup wizard hardware tests -- HIGH tier.
*
* Covers the detector step's probed radio list and the models: payload it
* writes, the model-required deferral for onnx hardware, the hwaccel step's
* Auto option writing the preset derived from the chosen hardware, and the
* completion screen only restarting when a saved step requires it.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
const NVIDIA_HARDWARE = [
{
key: "onnx:nvidia",
detector: "onnx",
name: "NVIDIA GeForce RTX 3060",
units: [{ device: "onnx:0", label: "NVIDIA GeForce RTX 3060" }],
count: 1,
unlimited: true,
},
{
key: "cpu",
detector: "cpu",
name: "CPU",
units: [{ device: "cpu", label: "CPU" }],
count: 1,
unlimited: true,
},
];
type SavedConfig = {
config_data?: {
models?: { devices: string[]; path?: string }[];
detect?: { enabled?: boolean };
ffmpeg?: { hwaccel_args?: string | string[] };
onboarding?: { setup_complete?: boolean };
};
};
async function captureSaves(page: Page): Promise<SavedConfig[]> {
const saves: SavedConfig[] = [];
await page.route("**/api/config/set**", (route) => {
saves.push(route.request().postDataJSON() as SavedConfig);
return route.fulfill({ json: { success: true, require_restart: true } });
});
return saves;
}
async function captureRestarts(page: Page): Promise<string[]> {
const calls: string[] = [];
await page.route("**/api/restart", (route) => {
calls.push(route.request().url());
return route.fulfill({ json: { success: true, message: "Restarting" } });
});
return calls;
}
async function gotoDetectorStep(page: Page) {
// welcome -> camera -> detector
await page.getByRole("button", { name: "Get Started" }).click();
await expect(page.getByText("Add Your First Camera")).toBeVisible();
// The camera step's Next button only renders once addedCameras is
// non-empty, and that state is local to the step (it isn't seeded from
// the cameras already in config). Opening and cancelling the add-camera
// dialog is enough: its close handler force-refetches /api/config, which
// picks up the cameras already present in the mocked config and reveals
// Next -- see SetupCamera.tsx's handleClose.
await page.getByRole("button", { name: "Add Camera" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Next" })).toBeVisible({
timeout: 10_000,
});
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Object Detection")).toBeVisible();
}
test.describe("setup wizard hardware @high @mobile", () => {
test("lists probed hardware and writes a models config", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({
// the base config snapshot predates the onboarding key, hence the cast
config: { onboarding: { setup_complete: false } } as never,
hwaccel: { preset: "preset-vaapi" },
});
const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page);
// the default hardware mock reports two Corals, an Intel GPU, and the CPU
await expect(
page.getByRole("radio", { name: /Coral EdgeTPU \(PCIe\) \(2\)/ }),
).toBeChecked();
await expect(page.getByText("Recommended")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
// detector -> hwaccel
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
const detectorSave = saves.find((save) => save.config_data?.models);
expect(detectorSave?.config_data?.models).toEqual([
{ devices: ["edgetpu:pci:0"] },
]);
expect(detectorSave?.config_data?.detect).toEqual({ enabled: true });
// Auto shows the preset derived from the chosen hardware and writes it
await expect(page.getByText("preset-vaapi")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
hwaccel_args: "preset-vaapi",
});
// hwaccel -> recording (skipped) -> complete, where the saved steps only
// take effect after a restart and the primary action says so
const restarts = await captureRestarts(page);
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("You're All Set!")).toBeVisible();
await expect(
page.getByText("Frigate needs to restart to apply your settings"),
).toBeVisible();
await page.getByRole("button", { name: "Apply & Restart" }).click();
await expect(page.getByText("Starting Frigate...")).toBeVisible();
expect(restarts).toHaveLength(1);
});
test("defers model setup for onnx hardware without Frigate+", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({
config: { onboarding: { setup_complete: false } } as never,
hardware: NVIDIA_HARDWARE,
});
const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page);
await expect(
page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }),
).toBeChecked();
await page
.getByRole("button", { name: "Continue without detection" })
.click();
// advances without touching the config
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
expect(saves.filter((save) => save.config_data?.models)).toHaveLength(0);
// with nothing derived, Auto advances without writing; skipping recording
// leaves no pending saves, so finishing needs no restart
const restarts = await captureRestarts(page);
await expect(page.getByText("No compatible GPU found")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("You're All Set!")).toBeVisible();
await expect(
page.getByText("Frigate needs to restart to apply your settings"),
).toBeHidden();
await page.getByRole("button", { name: "Go to Live View" }).click();
// hands off without restarting; the mocked config still reports
// onboarding incomplete, so the reload lands back on the wizard
await expect(page.getByText("Welcome to Frigate")).toBeVisible();
expect(restarts).toHaveLength(0);
const finishSave = saves.find((save) => save.config_data?.onboarding);
expect(finishSave?.config_data?.onboarding).toEqual({
setup_complete: true,
});
});
test("None writes an explicit empty hwaccel list", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({
config: { onboarding: { setup_complete: false } } as never,
});
const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page);
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
await page.getByRole("radio", { name: "None (software decoding)" }).click();
await page.getByRole("button", { name: "Next" }).click();
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({ hwaccel_args: [] });
});
});
+88
View File
@@ -0,0 +1,88 @@
{
"setupWizard": {
"steps": {
"welcome": "Welcome",
"camera": "Add Camera",
"hwaccel": "Acceleration",
"detector": "Detection",
"recording": "Recording",
"complete": "Done"
},
"welcome": {
"title": "Welcome to Frigate",
"description": "Let's get your security cameras set up. We'll walk through camera connection, hardware settings, and recording.",
"getStarted": "Get Started",
"skipSetup": "Skip setup and configure manually"
},
"camera": {
"title": "Add Your First Camera",
"description": "Connect a camera to start monitoring. You can also add more cameras later in Settings.",
"addCamera": "Add Camera",
"addAnother": "Add Another Camera",
"cameraAdded": "Camera added successfully",
"retry": "Try Again"
},
"hwaccel": {
"title": "Hardware Acceleration",
"description": "Speed up video decoding with your GPU.",
"detecting": "Checking your hardware...",
"auto": "Auto",
"autoResolved": "Will use {{preset}} based on your detection hardware.",
"autoNone": "No compatible GPU found. Frigate will decide at startup.",
"recommendFailed": "Hardware detection is unavailable. Frigate will decide at startup.",
"presets": {
"vaapi": "VAAPI (Intel/AMD)",
"cuda": "CUDA (NVIDIA)",
"qsv": "QuickSync (Intel)",
"rkmpp": "RKMPP (Rockchip)",
"rpi": "V4L2 (Raspberry Pi)",
"jetson": "NVIDIA Jetson",
"none": "None (software decoding)"
}
},
"detector": {
"title": "Object Detection",
"description": "Choose the hardware Frigate uses to detect people and objects.",
"detecting": "Checking for detection hardware...",
"probeFailed": "Hardware detection is unavailable. You can configure detection later in Settings.",
"recommended": "Recommended",
"modelRequired": "{{name}} needs a detection model before it can run. Pick a Frigate+ model, or finish setup and add one under Settings > Detection models.",
"plusModelPlaceholder": "Select a Frigate+ model",
"continueWithout": "Continue without detection"
},
"recording": {
"title": "Recordings",
"description": "Save video from your cameras for later review.",
"enableRecording": "Enable recordings",
"retentionDays": "Days to retain recordings",
"storageEstimate": "With {{free}} GB free, you can store approximately {{days}} days of recordings for {{cameras}} camera(s).",
"noCameras": "You haven't added cameras yet. Recording will apply when you add cameras in Settings."
},
"complete": {
"title": "You're All Set!",
"description": "Your Frigate system is configured. Here's what we set up:",
"configured": "Configured",
"notConfigured": "Not configured",
"configureInSettings": "Configure in Settings",
"camera": "Camera",
"hwaccel": "Hardware Acceleration",
"detector": "Object Detection",
"recording": "Recording",
"goToLiveView": "Go to Live View",
"applyAndRestart": "Apply & Restart",
"restartNotice": "Frigate needs to restart to apply your settings. This takes about 30 seconds.",
"nextSteps": "Next steps: Set up motion masks, zones, and notifications in Settings.",
"restarting": "Starting Frigate...",
"restartingDescription": "This takes about 30 seconds."
},
"actions": {
"next": "Next",
"back": "Back",
"skip": "Skip",
"saving": "Saving..."
},
"errors": {
"saveFailed": "Failed to save configuration. Please try again."
}
}
}
+16
View File
@@ -30,6 +30,7 @@ const Chat = lazy(() => import("@/pages/Chat"));
const Logs = lazy(() => import("@/pages/Logs")); const Logs = lazy(() => import("@/pages/Logs"));
const AccessDenied = lazy(() => import("@/pages/AccessDenied")); const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
const Replay = lazy(() => import("@/pages/Replay")); const Replay = lazy(() => import("@/pages/Replay"));
const SetupWizard = lazy(() => import("@/pages/SetupWizard"));
function App() { function App() {
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@@ -68,6 +69,21 @@ function DefaultAppView() {
); );
} }
// Show setup wizard for first-time users
if (config && config.onboarding?.setup_complete === false) {
return (
<div className="size-full overflow-hidden">
<Suspense
fallback={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
>
<SetupWizard />
</Suspense>
</div>
);
}
return ( return (
<div className="size-full overflow-hidden"> <div className="size-full overflow-hidden">
{isDesktop && <Sidebar />} {isDesktop && <Sidebar />}
+100
View File
@@ -0,0 +1,100 @@
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { Button } from "@/components/ui/button";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { FaCircleCheck } from "react-icons/fa6";
type SetupCameraProps = {
onNext: (cameraNames?: string[]) => void;
onBack: () => void;
};
export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
const { t } = useTranslation(["views/setup"]);
const [showWizard, setShowWizard] = useState(false);
const [addedCameras, setAddedCameras] = useState<string[]>([]);
const { mutate: mutateConfig } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const handleClose = useCallback(() => {
setShowWizard(false);
// Delay to let CameraWizardDialog's config save and go2rtc setup complete,
// then force a fresh fetch from the server (bypassing SWR cache)
setTimeout(() => {
fetch(`${window.baseUrl || ""}api/config`)
.then((res) => res.json())
.then((freshConfig: FrigateConfig) => {
const cameraNames = Object.keys(freshConfig.cameras || {});
if (cameraNames.length > addedCameras.length) {
mutateConfig(freshConfig, { revalidate: false });
setAddedCameras(cameraNames);
}
})
.catch(() => {
// Fetch failed, stay on this step
});
}, 1000);
}, [mutateConfig, addedCameras]);
const handleNext = useCallback(() => {
onNext(addedCameras.length > 0 ? addedCameras : undefined);
}, [onNext, addedCameras]);
return (
<>
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.camera.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.camera.description")}
</p>
</div>
{addedCameras.length > 0 && (
<div className="flex flex-col gap-2">
{addedCameras.map((name) => (
<div
key={name}
className="flex items-center justify-between rounded-md border p-3"
>
<span className="text-sm font-medium">{name}</span>
<FaCircleCheck className="size-4 text-success" />
</div>
))}
</div>
)}
<div className="flex flex-col items-center gap-3 py-4">
<Button
variant="select"
className="w-full"
onClick={() => setShowWizard(true)}
>
{addedCameras.length > 0
? t("setupWizard.camera.addAnother")
: t("setupWizard.camera.addCamera")}
</Button>
</div>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
{addedCameras.length > 0 && (
<Button type="button" variant="select" onClick={handleNext}>
{t("setupWizard.actions.next")}
</Button>
)}
</div>
</div>
</div>
<CameraWizardDialog open={showWizard} onClose={handleClose} />
</>
);
}
+218
View File
@@ -0,0 +1,218 @@
import Logo from "@/components/Logo";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import axios from "axios";
import { FaCircleCheck } from "react-icons/fa6";
type ConfiguredItem = {
key: string;
label: string;
value: string | null;
};
type SetupCompleteProps = {
cameraNames: string[];
configuredSteps: {
camera: boolean;
hwaccel: boolean;
detector: boolean;
recording: boolean;
};
restartRequired: boolean;
onBack: () => void;
};
export default function SetupComplete({
cameraNames,
configuredSteps,
restartRequired,
onBack,
}: SetupCompleteProps) {
const { t } = useTranslation(["views/setup"]);
const [restarting, setRestarting] = useState(false);
const [finishing, setFinishing] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cameraItems: ConfiguredItem[] =
configuredSteps.camera && cameraNames.length > 0
? cameraNames.map((name) => ({
key: `camera-${name}`,
label: t("setupWizard.complete.camera"),
value: name,
}))
: [
{
key: "camera",
label: t("setupWizard.complete.camera"),
value: null,
},
];
const items: ConfiguredItem[] = [
...cameraItems,
{
key: "hwaccel",
label: t("setupWizard.complete.hwaccel"),
value: configuredSteps.hwaccel
? t("setupWizard.complete.configured")
: null,
},
{
key: "detector",
label: t("setupWizard.complete.detector"),
value: configuredSteps.detector
? t("setupWizard.complete.configured")
: null,
},
{
key: "recording",
label: t("setupWizard.complete.recording"),
value: configuredSteps.recording
? t("setupWizard.complete.configured")
: null,
},
];
useEffect(() => {
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, []);
const handleFinish = useCallback(async () => {
setFinishing(true);
try {
// Mark setup as complete
await axios.put("config/set", {
config_data: {
onboarding: { setup_complete: true },
},
requires_restart: 0,
});
// camera adds were applied live, so with no pending requires_restart
// saves the wizard can hand off without restarting
if (!restartRequired) {
window.location.href = window.baseUrl || "/";
return;
}
setRestarting(true);
// Trigger restart
await axios.post("restart");
// Poll for server availability with max retries
let retries = 0;
const maxRetries = 60; // 2 minutes max
pollRef.current = setInterval(async () => {
retries++;
if (retries > maxRetries) {
if (pollRef.current) {
clearInterval(pollRef.current);
}
// Give up polling, redirect anyway
window.location.href = window.baseUrl || "/";
return;
}
try {
const resp = await axios.get("version", { timeout: 2000 });
if (resp.status === 200) {
if (pollRef.current) {
clearInterval(pollRef.current);
}
window.location.href = window.baseUrl || "/";
}
} catch {
// Server not ready yet
}
}, 2000);
} catch {
setRestarting(false);
setFinishing(false);
toast.error(t("setupWizard.errors.saveFailed"));
}
}, [restartRequired, t]);
if (restarting) {
return (
<div className="flex flex-col items-center gap-6 py-12">
<Logo className="h-12 w-12" />
<ActivityIndicator />
<div className="text-center">
<p className="font-semibold">
{t("setupWizard.complete.restarting")}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.complete.restartingDescription")}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.complete.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.complete.description")}
</p>
</div>
<div className="flex flex-col gap-2">
{items.map((item) => (
<div
key={item.key}
className="flex items-center justify-between rounded-md border p-3"
>
<span className="text-sm font-medium">{item.label}</span>
<div className="flex items-center gap-2">
{item.value && <FaCircleCheck className="size-4 text-success" />}
<span
className={`text-sm ${item.value ? "" : "text-muted-foreground"}`}
>
{item.value ?? t("setupWizard.complete.notConfigured")}
</span>
</div>
</div>
))}
</div>
<p className="text-sm text-muted-foreground">
{t("setupWizard.complete.nextSteps")}
</p>
{restartRequired && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.complete.restartNotice")}
</p>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end">
<Button
type="button"
variant="select"
onClick={handleFinish}
disabled={finishing}
>
{restartRequired
? t("setupWizard.complete.applyAndRestart")
: t("setupWizard.complete.goToLiveView")}
</Button>
</div>
</div>
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import type { FrigatePlusModel } from "@/components/config-form/theme/fields/ModelSourcePicker";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useDocDomain } from "@/hooks/use-doc-domain";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { DetectionHardware } from "@/types/hardware";
import { recommendedDetectorCount } from "@/utils/detectionHardware";
import axios from "axios";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
// detectors that ship no default model; configuring them without one leaves
// the detector unable to start
const MODEL_REQUIRED_DETECTORS = ["onnx", "tensorrt"];
const CPU_FALLBACK: DetectionHardware[] = [
{
key: "cpu",
detector: "cpu",
name: "CPU",
units: [{ device: "cpu", label: "CPU" }],
count: 1,
unlimited: true,
},
];
type SetupDetectorProps = {
cameraCount: number;
onNext: (hardwareKey: string) => void;
onBack: () => void;
onSkip: (hardwareKey?: string) => void;
};
export default function SetupDetector({
cameraCount,
onNext,
onBack,
onSkip,
}: SetupDetectorProps) {
const { t } = useTranslation(["views/setup", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const {
data: hardware,
isLoading,
error: probeError,
} = useSWR<DetectionHardware[]>("hardware/probe", {
revalidateOnFocus: false,
});
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const plusEnabled = Boolean(config?.plus?.enabled);
// the cpu is always probed, so an empty or failed probe leaves a fallback
const options = useMemo(
() => (hardware && hardware.length > 0 ? hardware : CPU_FALLBACK),
[hardware],
);
// the prober orders accelerators ahead of the cpu
const recommendedKey = options[0].key;
const [selectedKey, setSelectedKey] = useState<string>();
const selected =
options.find((entry) => entry.key === (selectedKey ?? recommendedKey)) ??
options[0];
const needsModel = MODEL_REQUIRED_DETECTORS.includes(selected.detector);
const { data: plusModels } = useSWR<FrigatePlusModel[]>(
plusEnabled && needsModel ? "/plus/models" : null,
{
fetcher: async (url) => {
const res = await axios.get(url, { withCredentials: true });
return res.data;
},
},
);
const [plusModelId, setPlusModelId] = useState("");
const compatiblePlusModels = useMemo(
() =>
(plusModels ?? []).filter((model) =>
model.supportedDetectors.includes(selected.detector),
),
[plusModels, selected.detector],
);
const [saving, setSaving] = useState(false);
const buildDevices = useCallback(
(entry: DetectionHardware): string[] => {
const first = entry.units[0]?.device;
if (!first) {
return [];
}
if (!entry.unlimited) {
return [first];
}
// repeating a device runs an extra inference process on it
const count = recommendedDetectorCount(Math.max(cameraCount, 1));
return Array.from({ length: count }, () => first);
},
[cameraCount],
);
const handleSave = useCallback(async () => {
if (needsModel && !plusModelId) {
// no model to run on this hardware yet, so leave the config untouched
onSkip(selected.key);
return;
}
setSaving(true);
try {
const model: Record<string, unknown> = {
devices: buildDevices(selected),
};
if (needsModel) {
model.path = `plus://${plusModelId}`;
}
await axios.put("config/set", {
config_data: {
models: [model],
detect: { enabled: true },
},
requires_restart: 1,
});
onNext(selected.key);
} catch {
toast.error(t("setupWizard.errors.saveFailed"));
} finally {
setSaving(false);
}
}, [needsModel, plusModelId, selected, buildDevices, onNext, onSkip, t]);
if (isLoading) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<ActivityIndicator />
<p className="text-sm text-muted-foreground">
{t("setupWizard.detector.detecting")}
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.detector.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.detector.description")}
</p>
</div>
{probeError && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.detector.probeFailed")}
</p>
)}
<RadioGroup
value={selected.key}
onValueChange={(value) => {
setSelectedKey(value);
setPlusModelId("");
}}
>
{options.map((entry) => (
<div key={entry.key} className="flex items-center space-x-2">
<RadioGroupItem
value={entry.key}
id={`detector-${entry.key}`}
className={
selected.key === entry.key
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label
htmlFor={`detector-${entry.key}`}
className="cursor-pointer text-sm font-medium"
>
{entry.name}
{entry.count > 1 ? ` (${entry.count})` : ""}
{entry.key === recommendedKey && entry.key !== "cpu" && (
<span className="ml-2 text-xs text-selected">
{t("setupWizard.detector.recommended")}
</span>
)}
</label>
</div>
))}
</RadioGroup>
{needsModel && (
<div className="flex flex-col gap-3 rounded-md bg-muted p-3 text-sm">
<p>
{t("setupWizard.detector.modelRequired", { name: selected.name })}
</p>
{plusEnabled ? (
<Select value={plusModelId} onValueChange={setPlusModelId}>
<SelectTrigger className="max-w-xs">
<SelectValue
placeholder={t("setupWizard.detector.plusModelPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{compatiblePlusModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{`${model.name} (${model.width}x${model.height})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<a
href={getLocaleDocUrl("configuration/object_detectors")}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-primary"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 size-3" />
</a>
)}
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => onSkip(selected.key)}
>
{t("setupWizard.actions.skip")}
</Button>
<Button
type="button"
variant="select"
onClick={handleSave}
disabled={saving}
>
{saving
? t("setupWizard.actions.saving")
: needsModel && !plusModelId
? t("setupWizard.detector.continueWithout")
: t("setupWizard.actions.next")}
</Button>
</div>
</div>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { HwaccelRecommendation } from "@/types/hardware";
import axios from "axios";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import useSWR from "swr";
const AUTO = "auto";
const NONE = "none";
const HWACCEL_PRESETS = [
{ value: "preset-vaapi", key: "vaapi" },
{ value: "preset-nvidia", key: "cuda" },
{ value: "preset-intel-qsv-h264", key: "qsv" },
{ value: "preset-rkmpp", key: "rkmpp" },
{ value: "preset-rpi-64-h264", key: "rpi" },
{ value: "preset-jetson-h264", key: "jetson" },
] as const;
type SetupHwAccelProps = {
detectorHardwareKey?: string;
// saved reports whether a config write happened, so the wizard knows
// whether finishing requires a restart
onNext: (saved: boolean) => void;
onBack: () => void;
onSkip: () => void;
};
export default function SetupHwAccel({
detectorHardwareKey,
onNext,
onBack,
onSkip,
}: SetupHwAccelProps) {
const { t } = useTranslation(["views/setup"]);
const {
data: recommendation,
isLoading,
error: recommendError,
} = useSWR<HwaccelRecommendation>(
detectorHardwareKey
? `hardware/hwaccel?detector=${encodeURIComponent(detectorHardwareKey)}`
: "hardware/hwaccel",
{ revalidateOnFocus: false },
);
const [selected, setSelected] = useState<string>(AUTO);
const [saving, setSaving] = useState(false);
const derived = recommendation?.preset ?? "";
const handleSave = useCallback(async () => {
// Auto with nothing derived writes nothing: the config default of "auto"
// stays in place and the backend decides at startup
if (selected === AUTO && !derived) {
onNext(false);
return;
}
// an empty string would make config/set delete the key (reviving the
// "auto" default), so an explicit no-hwaccel is an empty list
const preset: string | string[] =
selected === AUTO ? derived : selected === NONE ? [] : selected;
setSaving(true);
try {
await axios.put("config/set", {
config_data: {
ffmpeg: { hwaccel_args: preset },
},
requires_restart: 1,
});
onNext(true);
} catch {
toast.error(t("setupWizard.errors.saveFailed"));
} finally {
setSaving(false);
}
}, [selected, derived, onNext, t]);
if (isLoading) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<ActivityIndicator />
<p className="text-sm text-muted-foreground">
{t("setupWizard.hwaccel.detecting")}
</p>
</div>
);
}
const radioClass = (value: string) =>
selected === value
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary";
return (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.hwaccel.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.hwaccel.description")}
</p>
</div>
<RadioGroup value={selected} onValueChange={setSelected}>
<div className="flex flex-col gap-0.5">
<div className="flex items-center space-x-2">
<RadioGroupItem
value={AUTO}
id="hwaccel-auto"
className={radioClass(AUTO)}
/>
<label htmlFor="hwaccel-auto" className="cursor-pointer text-sm">
{t("setupWizard.hwaccel.auto")}
</label>
</div>
<p className="ml-6 text-xs text-muted-foreground">
{derived
? t("setupWizard.hwaccel.autoResolved", { preset: derived })
: recommendError
? t("setupWizard.hwaccel.recommendFailed")
: t("setupWizard.hwaccel.autoNone")}
</p>
</div>
{HWACCEL_PRESETS.map((preset) => (
<div key={preset.key} className="flex items-center space-x-2">
<RadioGroupItem
value={preset.value}
id={`hwaccel-${preset.key}`}
className={radioClass(preset.value)}
/>
<label
htmlFor={`hwaccel-${preset.key}`}
className="cursor-pointer text-sm"
>
{t(`setupWizard.hwaccel.presets.${preset.key}`)}
</label>
</div>
))}
<div className="flex items-center space-x-2">
<RadioGroupItem
value={NONE}
id="hwaccel-none"
className={radioClass(NONE)}
/>
<label htmlFor="hwaccel-none" className="cursor-pointer text-sm">
{t("setupWizard.hwaccel.presets.none")}
</label>
</div>
</RadioGroup>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
<Button type="button" variant="outline" onClick={onSkip}>
{t("setupWizard.actions.skip")}
</Button>
<Button
type="button"
variant="select"
onClick={handleSave}
disabled={saving}
>
{saving
? t("setupWizard.actions.saving")
: t("setupWizard.actions.next")}
</Button>
</div>
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import axios from "axios";
import useSWR from "swr";
type SetupRecordingProps = {
cameraNames: string[];
onNext: () => void;
onBack: () => void;
onSkip: () => void;
};
export default function SetupRecording({
cameraNames,
onNext,
onBack,
onSkip,
}: SetupRecordingProps) {
const { t } = useTranslation(["views/setup"]);
const [enabled, setEnabled] = useState(true);
const [retentionDays, setRetentionDays] = useState(10);
const [saving, setSaving] = useState(false);
const { data: stats } = useSWR("stats", { revalidateOnFocus: false });
// Calculate storage estimate
const storageInfo = stats?.service?.storage?.["/tmp/frigate/recordings"];
const freeGb = storageInfo ? Math.round(storageInfo.free / 1024) : null;
const cameraCount = cameraNames.length;
// Rough estimate: ~2 Mbps per camera continuous recording
const estimatedDays =
freeGb && cameraCount > 0
? Math.round((freeGb * 1024) / ((2 * 0.125 * 86400) / 1024) / cameraCount)
: null;
const handleSave = useCallback(async () => {
setSaving(true);
try {
await axios.put("config/set", {
config_data: {
record: {
enabled,
alerts: { retain: { days: retentionDays } },
detections: { retain: { days: retentionDays } },
},
},
requires_restart: 1,
});
onNext();
} catch {
toast.error(t("setupWizard.errors.saveFailed"));
} finally {
setSaving(false);
}
}, [enabled, retentionDays, onNext, t]);
return (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.recording.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.recording.description")}
</p>
</div>
{cameraCount === 0 && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.recording.noCameras")}
</p>
)}
<div className="flex items-center justify-between rounded-md border p-4">
<Label htmlFor="recording-toggle" className="font-medium">
{t("setupWizard.recording.enableRecording")}
</Label>
<Switch
id="recording-toggle"
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
{enabled && (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="retention-days">
{t("setupWizard.recording.retentionDays")}
</Label>
<Input
id="retention-days"
type="number"
min={1}
max={365}
value={retentionDays}
onChange={(e) =>
setRetentionDays(Math.max(1, parseInt(e.target.value) || 1))
}
/>
</div>
{freeGb !== null && estimatedDays !== null && cameraCount > 0 && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.recording.storageEstimate", {
free: freeGb,
days: estimatedDays,
cameras: cameraCount,
})}
</p>
)}
</>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
<Button type="button" variant="outline" onClick={onSkip}>
{t("setupWizard.actions.skip")}
</Button>
<Button
type="button"
variant="select"
onClick={handleSave}
disabled={saving}
>
{saving
? t("setupWizard.actions.saving")
: t("setupWizard.actions.next")}
</Button>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Logo from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
type SetupWelcomeProps = {
onNext: () => void;
onSkip: () => void;
};
export default function SetupWelcome({ onNext, onSkip }: SetupWelcomeProps) {
const { t } = useTranslation(["views/setup"]);
return (
<div className="flex flex-col items-center gap-6 py-4">
<Logo className="h-16 w-16" />
<div className="text-center">
<h2 className="text-2xl font-semibold">
{t("setupWizard.welcome.title")}
</h2>
<p className="mt-2 text-muted-foreground">
{t("setupWizard.welcome.description")}
</p>
</div>
<div className="flex w-full flex-col gap-3 pt-4">
<Button variant="select" className="w-full" onClick={onNext}>
{t("setupWizard.welcome.getStarted")}
</Button>
<button
className="text-sm text-muted-foreground hover:text-primary"
onClick={onSkip}
>
{t("setupWizard.welcome.skipSetup")}
</button>
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import StepIndicator from "@/components/indicators/StepIndicator";
import SetupCamera from "@/components/setup/SetupCamera";
import SetupComplete from "@/components/setup/SetupComplete";
import SetupDetector from "@/components/setup/SetupDetector";
import SetupHwAccel from "@/components/setup/SetupHwAccel";
import SetupRecording from "@/components/setup/SetupRecording";
import SetupWelcome from "@/components/setup/SetupWelcome";
import { Card, CardContent } from "@/components/ui/card";
import { useCallback, useReducer } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import axios from "axios";
const STEPS = [
"setupWizard.steps.welcome",
"setupWizard.steps.camera",
"setupWizard.steps.detector",
"setupWizard.steps.hwaccel",
"setupWizard.steps.recording",
"setupWizard.steps.complete",
];
type WizardState = {
currentStep: number;
cameraNames: string[];
detectorHardwareKey?: string;
// whether any step wrote config with requires_restart, which only takes
// effect after a restart (camera adds apply live and don't count)
restartRequired: boolean;
configuredSteps: {
camera: boolean;
hwaccel: boolean;
detector: boolean;
recording: boolean;
};
};
type WizardAction =
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| { type: "CAMERAS_ADDED"; cameraNames: string[] }
| {
type: "STEP_CONFIGURED";
step: keyof WizardState["configuredSteps"];
savedConfig: boolean;
}
| { type: "DETECTOR_DONE"; configured: boolean; hardwareKey?: string }
| { type: "SKIP_STEP" };
const initialState: WizardState = {
currentStep: 0,
cameraNames: [],
restartRequired: false,
configuredSteps: {
camera: false,
hwaccel: false,
detector: false,
recording: false,
},
};
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "NEXT_STEP":
return { ...state, currentStep: state.currentStep + 1 };
case "PREV_STEP":
return {
...state,
currentStep: Math.max(0, state.currentStep - 1),
};
case "CAMERAS_ADDED":
return {
...state,
currentStep: state.currentStep + 1,
cameraNames: action.cameraNames,
configuredSteps: { ...state.configuredSteps, camera: true },
};
case "STEP_CONFIGURED":
return {
...state,
currentStep: state.currentStep + 1,
restartRequired: state.restartRequired || action.savedConfig,
configuredSteps: { ...state.configuredSteps, [action.step]: true },
};
case "DETECTOR_DONE":
return {
...state,
currentStep: state.currentStep + 1,
detectorHardwareKey: action.hardwareKey ?? state.detectorHardwareKey,
restartRequired: state.restartRequired || action.configured,
configuredSteps: {
...state.configuredSteps,
detector: state.configuredSteps.detector || action.configured,
},
};
case "SKIP_STEP":
return { ...state, currentStep: state.currentStep + 1 };
default:
return state;
}
}
export default function SetupWizard() {
const { t } = useTranslation(["views/setup"]);
const [state, dispatch] = useReducer(wizardReducer, initialState);
const handleSkipSetup = useCallback(async () => {
try {
await axios.put("config/set", {
config_data: {
onboarding: { setup_complete: true },
},
requires_restart: 0,
});
window.location.href = window.baseUrl || "/";
} catch {
toast.error(t("setupWizard.errors.saveFailed"));
}
}, [t]);
const handleCameraNext = useCallback((cameraNames?: string[]) => {
if (cameraNames && cameraNames.length > 0) {
dispatch({ type: "CAMERAS_ADDED", cameraNames });
} else {
dispatch({ type: "SKIP_STEP" });
}
}, []);
const handleHwAccelNext = useCallback((saved: boolean) => {
dispatch({ type: "STEP_CONFIGURED", step: "hwaccel", savedConfig: saved });
}, []);
const handleDetectorNext = useCallback((hardwareKey: string) => {
dispatch({ type: "DETECTOR_DONE", configured: true, hardwareKey });
}, []);
const handleDetectorSkip = useCallback((hardwareKey?: string) => {
dispatch({ type: "DETECTOR_DONE", configured: false, hardwareKey });
}, []);
const handleRecordingNext = useCallback(() => {
dispatch({ type: "STEP_CONFIGURED", step: "recording", savedConfig: true });
}, []);
const handleBack = useCallback(() => {
dispatch({ type: "PREV_STEP" });
}, []);
const handleSkipStep = useCallback(() => {
dispatch({ type: "SKIP_STEP" });
}, []);
const renderStep = () => {
switch (state.currentStep) {
case 0:
return (
<SetupWelcome
onNext={() => dispatch({ type: "NEXT_STEP" })}
onSkip={handleSkipSetup}
/>
);
case 1:
return <SetupCamera onNext={handleCameraNext} onBack={handleBack} />;
case 2:
return (
<SetupDetector
cameraCount={state.cameraNames.length}
onNext={handleDetectorNext}
onBack={handleBack}
onSkip={handleDetectorSkip}
/>
);
case 3:
return (
<SetupHwAccel
detectorHardwareKey={state.detectorHardwareKey}
onNext={handleHwAccelNext}
onBack={handleBack}
onSkip={handleSkipStep}
/>
);
case 4:
return (
<SetupRecording
cameraNames={state.cameraNames}
onNext={handleRecordingNext}
onBack={handleBack}
onSkip={handleSkipStep}
/>
);
case 5:
return (
<SetupComplete
cameraNames={state.cameraNames}
configuredSteps={state.configuredSteps}
restartRequired={state.restartRequired}
onBack={handleBack}
/>
);
default:
return null;
}
};
return (
<div className="flex min-h-dvh items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardContent className="p-6">
<StepIndicator
steps={STEPS}
currentStep={state.currentStep}
variant="dots"
translationNameSpace="views/setup"
className="mb-4 justify-start"
/>
<div className="fade-in">{renderStep()}</div>
</CardContent>
</Card>
</div>
);
}
+4
View File
@@ -657,5 +657,9 @@ export interface FrigateConfig {
thickness: number; thickness: number;
}; };
onboarding: {
setup_complete: boolean;
};
ui: UiConfig; ui: UiConfig;
} }
+4
View File
@@ -11,3 +11,7 @@ export type DetectionHardware = {
count: number; count: number;
unlimited: boolean; unlimited: boolean;
}; };
export type HwaccelRecommendation = {
preset: string;
};