mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 19:06:52 +03:00
add onboarding wizard for new users
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
@@ -43,6 +43,7 @@ export interface ApiMockOverrides {
|
||||
configRaw?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
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: {} }),
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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 (
|
||||
<div className="size-full overflow-hidden">
|
||||
{isDesktop && <Sidebar />}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -657,5 +657,9 @@ export interface FrigateConfig {
|
||||
thickness: number;
|
||||
};
|
||||
|
||||
onboarding: {
|
||||
setup_complete: boolean;
|
||||
};
|
||||
|
||||
ui: UiConfig;
|
||||
}
|
||||
|
||||
@@ -11,3 +11,7 @@ export type DetectionHardware = {
|
||||
count: number;
|
||||
unlimited: boolean;
|
||||
};
|
||||
|
||||
export type HwaccelRecommendation = {
|
||||
preset: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user