diff --git a/frigate/api/hardware.py b/frigate/api/hardware.py index aba5a88a46..43e2157881 100644 --- a/frigate/api/hardware.py +++ b/frigate/api/hardware.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends from frigate.api.auth import require_role from frigate.api.defs.tags import Tags from frigate.detectors.hardware import DetectionHardware, hardware_prober +from frigate.util.hwaccel import HwaccelRecommendation, recommend_hwaccel logger = logging.getLogger(__name__) @@ -28,3 +29,21 @@ def probe_hardware(refresh: bool = False) -> list[DetectionHardware]: Every kind of detection hardware that was found """ 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)) diff --git a/frigate/config/config.py b/frigate/config/config.py index a003e138cc..d54dd09954 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -17,6 +17,7 @@ from pydantic import ( ) from ruamel.yaml import YAML +from frigate.config.onboarding import OnboardingConfig from frigate.const import REGEX_JSON from frigate.detectors import ModelConfig 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.", ) + 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( default=None, title="Active profile", diff --git a/frigate/config/onboarding.py b/frigate/config/onboarding.py new file mode 100644 index 0000000000..d245ecbabf --- /dev/null +++ b/frigate/config/onboarding.py @@ -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.", + ) diff --git a/frigate/test/test_hwaccel_recommendation.py b/frigate/test/test_hwaccel_recommendation.py new file mode 100644 index 0000000000..ce69f2896d --- /dev/null +++ b/frigate/test/test_hwaccel_recommendation.py @@ -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() diff --git a/frigate/util/config.py b/frigate/util/config.py index 4f221cafe9..ad550d67e6 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -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 + # 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" return new_config diff --git a/frigate/util/hwaccel.py b/frigate/util/hwaccel.py new file mode 100644 index 0000000000..718c7a91fd --- /dev/null +++ b/frigate/util/hwaccel.py @@ -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 "" diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index cc062abf79..c2ceb7d6d1 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -43,6 +43,7 @@ export interface ApiMockOverrides { configRaw?: string; configSchema?: Record; hardware?: unknown[]; + hwaccel?: { preset: string }; } export class ApiMocker { @@ -185,6 +186,11 @@ export class ApiMocker { 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 await this.page.route("**/api/go2rtc/streams**", (route) => route.fulfill({ json: {} }), diff --git a/web/e2e/specs/setup/wizard-hardware.spec.ts b/web/e2e/specs/setup/wizard-hardware.spec.ts new file mode 100644 index 0000000000..43ef2e9e8e --- /dev/null +++ b/web/e2e/specs/setup/wizard-hardware.spec.ts @@ -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 { + 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 { + 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: [] }); + }); +}); diff --git a/web/public/locales/en/views/setup.json b/web/public/locales/en/views/setup.json new file mode 100644 index 0000000000..dc2f80e8d7 --- /dev/null +++ b/web/public/locales/en/views/setup.json @@ -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." + } + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 550bc36c81..dba3c97b10 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,7 @@ const Chat = lazy(() => import("@/pages/Chat")); const Logs = lazy(() => import("@/pages/Logs")); const AccessDenied = lazy(() => import("@/pages/AccessDenied")); const Replay = lazy(() => import("@/pages/Replay")); +const SetupWizard = lazy(() => import("@/pages/SetupWizard")); function App() { const { data: config } = useSWR("config", { @@ -68,6 +69,21 @@ function DefaultAppView() { ); } + // Show setup wizard for first-time users + if (config && config.onboarding?.setup_complete === false) { + return ( +
+ + } + > + + +
+ ); + } + return (
{isDesktop && } diff --git a/web/src/components/setup/SetupCamera.tsx b/web/src/components/setup/SetupCamera.tsx new file mode 100644 index 0000000000..e4a006c03c --- /dev/null +++ b/web/src/components/setup/SetupCamera.tsx @@ -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([]); + const { mutate: mutateConfig } = useSWR("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 ( + <> +
+
+

+ {t("setupWizard.camera.title")} +

+

+ {t("setupWizard.camera.description")} +

+
+ + {addedCameras.length > 0 && ( +
+ {addedCameras.map((name) => ( +
+ {name} + +
+ ))} +
+ )} + +
+ +
+ +
+ +
+ {addedCameras.length > 0 && ( + + )} +
+
+
+ + + ); +} diff --git a/web/src/components/setup/SetupComplete.tsx b/web/src/components/setup/SetupComplete.tsx new file mode 100644 index 0000000000..7616ac166e --- /dev/null +++ b/web/src/components/setup/SetupComplete.tsx @@ -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 | 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 ( +
+ + +
+

+ {t("setupWizard.complete.restarting")} +

+

+ {t("setupWizard.complete.restartingDescription")} +

+
+
+ ); + } + + return ( +
+
+

+ {t("setupWizard.complete.title")} +

+

+ {t("setupWizard.complete.description")} +

+
+ +
+ {items.map((item) => ( +
+ {item.label} +
+ {item.value && } + + {item.value ?? t("setupWizard.complete.notConfigured")} + +
+
+ ))} +
+ +

+ {t("setupWizard.complete.nextSteps")} +

+ + {restartRequired && ( +

+ {t("setupWizard.complete.restartNotice")} +

+ )} + +
+ +
+ +
+
+
+ ); +} diff --git a/web/src/components/setup/SetupDetector.tsx b/web/src/components/setup/SetupDetector.tsx new file mode 100644 index 0000000000..6dce2e0994 --- /dev/null +++ b/web/src/components/setup/SetupDetector.tsx @@ -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("hardware/probe", { + revalidateOnFocus: false, + }); + + const { data: config } = useSWR("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(); + const selected = + options.find((entry) => entry.key === (selectedKey ?? recommendedKey)) ?? + options[0]; + + const needsModel = MODEL_REQUIRED_DETECTORS.includes(selected.detector); + + const { data: plusModels } = useSWR( + 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 = { + 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 ( +
+ +

+ {t("setupWizard.detector.detecting")} +

+
+ ); + } + + return ( +
+
+

+ {t("setupWizard.detector.title")} +

+

+ {t("setupWizard.detector.description")} +

+
+ + {probeError && ( +

+ {t("setupWizard.detector.probeFailed")} +

+ )} + + { + setSelectedKey(value); + setPlusModelId(""); + }} + > + {options.map((entry) => ( +
+ + +
+ ))} +
+ + {needsModel && ( +
+

+ {t("setupWizard.detector.modelRequired", { name: selected.name })} +

+ {plusEnabled ? ( + + ) : ( + + {t("readTheDocumentation", { ns: "common" })} + + + )} +
+ )} + +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/components/setup/SetupHwAccel.tsx b/web/src/components/setup/SetupHwAccel.tsx new file mode 100644 index 0000000000..7dac5ca663 --- /dev/null +++ b/web/src/components/setup/SetupHwAccel.tsx @@ -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( + detectorHardwareKey + ? `hardware/hwaccel?detector=${encodeURIComponent(detectorHardwareKey)}` + : "hardware/hwaccel", + { revalidateOnFocus: false }, + ); + + const [selected, setSelected] = useState(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 ( +
+ +

+ {t("setupWizard.hwaccel.detecting")} +

+
+ ); + } + + 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 ( +
+
+

+ {t("setupWizard.hwaccel.title")} +

+

+ {t("setupWizard.hwaccel.description")} +

+
+ + +
+
+ + +
+

+ {derived + ? t("setupWizard.hwaccel.autoResolved", { preset: derived }) + : recommendError + ? t("setupWizard.hwaccel.recommendFailed") + : t("setupWizard.hwaccel.autoNone")} +

+
+ + {HWACCEL_PRESETS.map((preset) => ( +
+ + +
+ ))} + +
+ + +
+
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/components/setup/SetupRecording.tsx b/web/src/components/setup/SetupRecording.tsx new file mode 100644 index 0000000000..e97c987722 --- /dev/null +++ b/web/src/components/setup/SetupRecording.tsx @@ -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 ( +
+
+

+ {t("setupWizard.recording.title")} +

+

+ {t("setupWizard.recording.description")} +

+
+ + {cameraCount === 0 && ( +

+ {t("setupWizard.recording.noCameras")} +

+ )} + +
+ + +
+ + {enabled && ( + <> +
+ + + setRetentionDays(Math.max(1, parseInt(e.target.value) || 1)) + } + /> +
+ + {freeGb !== null && estimatedDays !== null && cameraCount > 0 && ( +

+ {t("setupWizard.recording.storageEstimate", { + free: freeGb, + days: estimatedDays, + cameras: cameraCount, + })} +

+ )} + + )} + +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/components/setup/SetupWelcome.tsx b/web/src/components/setup/SetupWelcome.tsx new file mode 100644 index 0000000000..cea818db65 --- /dev/null +++ b/web/src/components/setup/SetupWelcome.tsx @@ -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 ( +
+ +
+

+ {t("setupWizard.welcome.title")} +

+

+ {t("setupWizard.welcome.description")} +

+
+
+ + +
+
+ ); +} diff --git a/web/src/pages/SetupWizard.tsx b/web/src/pages/SetupWizard.tsx new file mode 100644 index 0000000000..e19dc46bd7 --- /dev/null +++ b/web/src/pages/SetupWizard.tsx @@ -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 ( + dispatch({ type: "NEXT_STEP" })} + onSkip={handleSkipSetup} + /> + ); + case 1: + return ; + case 2: + return ( + + ); + case 3: + return ( + + ); + case 4: + return ( + + ); + case 5: + return ( + + ); + default: + return null; + } + }; + + return ( +
+ + + + +
{renderStep()}
+
+
+
+ ); +} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index fcb6a9aace..452443f3cf 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -657,5 +657,9 @@ export interface FrigateConfig { thickness: number; }; + onboarding: { + setup_complete: boolean; + }; + ui: UiConfig; } diff --git a/web/src/types/hardware.ts b/web/src/types/hardware.ts index e77de8cd93..9bd24e3008 100644 --- a/web/src/types/hardware.ts +++ b/web/src/types/hardware.ts @@ -11,3 +11,7 @@ export type DetectionHardware = { count: number; unlimited: boolean; }; + +export type HwaccelRecommendation = { + preset: string; +};