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.auth import require_role
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.detectors.hardware import DetectionHardware, hardware_prober
|
from frigate.detectors.hardware import DetectionHardware, hardware_prober
|
||||||
|
from frigate.util.hwaccel import HwaccelRecommendation, recommend_hwaccel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,3 +29,21 @@ def probe_hardware(refresh: bool = False) -> list[DetectionHardware]:
|
|||||||
Every kind of detection hardware that was found
|
Every kind of detection hardware that was found
|
||||||
"""
|
"""
|
||||||
return hardware_prober.probe(refresh=refresh)
|
return hardware_prober.probe(refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/hardware/hwaccel",
|
||||||
|
response_model=HwaccelRecommendation,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
|
def hwaccel_recommendation(detector: str | None = None) -> HwaccelRecommendation:
|
||||||
|
"""Get the ffmpeg hwaccel preset recommended for this system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detector: Hardware key of the detection hardware in use, which biases
|
||||||
|
the recommendation toward that hardware's GPU
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The recommended preset, empty when no hardware acceleration fits
|
||||||
|
"""
|
||||||
|
return HwaccelRecommendation(preset=recommend_hwaccel(detector))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from pydantic import (
|
|||||||
)
|
)
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
from frigate.config.onboarding import OnboardingConfig
|
||||||
from frigate.const import REGEX_JSON
|
from frigate.const import REGEX_JSON
|
||||||
from frigate.detectors import ModelConfig
|
from frigate.detectors import ModelConfig
|
||||||
from frigate.detectors.detector_config import SceneEnum
|
from frigate.detectors.detector_config import SceneEnum
|
||||||
@@ -636,6 +637,12 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
description="Named profile definitions with friendly names. Camera profiles must reference names defined here.",
|
description="Named profile definitions with friendly names. Camera profiles must reference names defined here.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onboarding: OnboardingConfig = Field(
|
||||||
|
default_factory=OnboardingConfig,
|
||||||
|
title="Onboarding",
|
||||||
|
description="First-time setup wizard state. Tracks whether the setup wizard has been completed or dismissed.",
|
||||||
|
)
|
||||||
|
|
||||||
active_profile: str | None = Field(
|
active_profile: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
title="Active profile",
|
title="Active profile",
|
||||||
|
|||||||
@@ -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
|
new_config["cameras"][name] = camera_config
|
||||||
|
|
||||||
|
# Set setup_complete to true for existing configs that already have cameras,
|
||||||
|
# so the setup wizard does not appear for users upgrading from older versions.
|
||||||
|
cameras = new_config.get("cameras", {})
|
||||||
|
if len(cameras) > 0:
|
||||||
|
onboarding = new_config.get("onboarding", {})
|
||||||
|
onboarding["setup_complete"] = True
|
||||||
|
new_config["onboarding"] = onboarding
|
||||||
|
|
||||||
new_config["version"] = "0.19-0"
|
new_config["version"] = "0.19-0"
|
||||||
return new_config
|
return new_config
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
configRaw?: string;
|
||||||
configSchema?: Record<string, unknown>;
|
configSchema?: Record<string, unknown>;
|
||||||
hardware?: unknown[];
|
hardware?: unknown[];
|
||||||
|
hwaccel?: { preset: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiMocker {
|
export class ApiMocker {
|
||||||
@@ -185,6 +186,11 @@ export class ApiMocker {
|
|||||||
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
|
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Hwaccel preset recommendation
|
||||||
|
await this.page.route("**/api/hardware/hwaccel**", (route) =>
|
||||||
|
route.fulfill({ json: overrides?.hwaccel ?? { preset: "" } }),
|
||||||
|
);
|
||||||
|
|
||||||
// Go2RTC streams
|
// Go2RTC streams
|
||||||
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
||||||
route.fulfill({ json: {} }),
|
route.fulfill({ json: {} }),
|
||||||
|
|||||||
@@ -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 Logs = lazy(() => import("@/pages/Logs"));
|
||||||
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||||
const Replay = lazy(() => import("@/pages/Replay"));
|
const Replay = lazy(() => import("@/pages/Replay"));
|
||||||
|
const SetupWizard = lazy(() => import("@/pages/SetupWizard"));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@@ -68,6 +69,21 @@ function DefaultAppView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show setup wizard for first-time users
|
||||||
|
if (config && config.onboarding?.setup_complete === false) {
|
||||||
|
return (
|
||||||
|
<div className="size-full overflow-hidden">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SetupWizard />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
{isDesktop && <Sidebar />}
|
{isDesktop && <Sidebar />}
|
||||||
|
|||||||
@@ -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;
|
thickness: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
setup_complete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
ui: UiConfig;
|
ui: UiConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,7 @@ export type DetectionHardware = {
|
|||||||
count: number;
|
count: number;
|
||||||
unlimited: boolean;
|
unlimited: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HwaccelRecommendation = {
|
||||||
|
preset: string;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user