diff --git a/frigate/config/config.py b/frigate/config/config.py index d54dd09954..a003e138cc 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -17,7 +17,6 @@ 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 @@ -637,12 +636,6 @@ class FrigateConfig(FrigateBaseModel): description="Named profile definitions with friendly names. Camera profiles must reference names defined here.", ) - onboarding: OnboardingConfig = Field( - default_factory=OnboardingConfig, - title="Onboarding", - description="First-time setup wizard state. Tracks whether the setup wizard has been completed or dismissed.", - ) - active_profile: str | None = Field( default=None, title="Active profile", diff --git a/frigate/config/onboarding.py b/frigate/config/onboarding.py deleted file mode 100644 index d245ecbabf..0000000000 --- a/frigate/config/onboarding.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Onboarding configuration for first-time setup wizard.""" - -from pydantic import Field - -from frigate.config.base import FrigateBaseModel - - -class OnboardingConfig(FrigateBaseModel): - setup_complete: bool = Field( - default=False, - title="Setup complete", - description="Set to true after the first-time setup wizard is completed or dismissed.", - ) diff --git a/frigate/util/config.py b/frigate/util/config.py index 8c5ef1fba6..d1609a3965 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -743,14 +743,6 @@ def migrate_019_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] new_config["cameras"][name] = camera_config - # Set setup_complete to true for existing configs that already have cameras, - # so the setup wizard does not appear for users upgrading from older versions. - cameras = new_config.get("cameras", {}) - if len(cameras) > 0: - onboarding = new_config.get("onboarding", {}) - onboarding["setup_complete"] = True - new_config["onboarding"] = onboarding - new_config["version"] = "0.19-0" return new_config diff --git a/web/e2e/specs/setup/wizard-hardware.spec.ts b/web/e2e/specs/setup/wizard-hardware.spec.ts index 43b8b8fab9..c1ae05b96b 100644 --- a/web/e2e/specs/setup/wizard-hardware.spec.ts +++ b/web/e2e/specs/setup/wizard-hardware.spec.ts @@ -9,6 +9,8 @@ import { test, expect } from "../../fixtures/frigate-test"; import type { Page } from "@playwright/test"; +import { configFactory } from "../../fixtures/mock-data/config"; +import type { ApiMockOverrides } from "../../helpers/api-mocker"; const NVIDIA_HARDWARE = [ { @@ -34,7 +36,6 @@ type SavedConfig = { models?: { devices: string[]; path?: string }[]; detect?: { enabled?: boolean }; ffmpeg?: { hwaccel_args?: string | string[] }; - onboarding?: { setup_complete?: boolean }; }; }; @@ -56,7 +57,34 @@ async function captureRestarts(page: Page): Promise { return calls; } -async function gotoDetectorStep(page: Page) { +/** + * Install mocks for a first run: the wizard shows when config has no cameras, + * so the config route starts empty and only reports cameras once the returned + * callback is fired, standing in for the camera the user adds. + */ +async function installFirstRun( + frigateApp: { installDefaults: (o?: ApiMockOverrides) => Promise }, + page: Page, + overrides?: ApiMockOverrides, +): Promise<() => void> { + await frigateApp.installDefaults(overrides); + + const full = configFactory(overrides?.config); + let cameras: unknown = {}; + + await page.route("**/api/config", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ json: { ...full, cameras } }); + } + return route.fulfill({ json: { success: true } }); + }); + + return () => { + cameras = full.cameras; + }; +} + +async function gotoDetectorStep(page: Page, addCamera: () => void) { await page.getByRole("button", { name: "Get Started" }).click(); await expect(page.getByText("Add Your First Camera")).toBeVisible(); @@ -64,6 +92,7 @@ async function gotoDetectorStep(page: Page) { // which SetupCamera does by refetching config when the dialog closes, so // opening and cancelling the dialog is what reveals Next await page.getByRole("button", { name: "Add Camera" }).click(); + addCamera(); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.getByRole("button", { name: "Next" })).toBeVisible({ @@ -78,9 +107,7 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - await frigateApp.installDefaults({ - // the base config snapshot predates the onboarding key, hence the cast - config: { onboarding: { setup_complete: false } } as never, + const addCamera = await installFirstRun(frigateApp, page, { hwaccel: { recommended: "vaapi", available: [ @@ -98,7 +125,7 @@ test.describe("setup wizard hardware @high @mobile", () => { const saves = await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page); + await gotoDetectorStep(page, addCamera); // the default hardware mock reports two Corals, an Intel GPU, and the CPU await expect( @@ -143,14 +170,13 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - await frigateApp.installDefaults({ - config: { onboarding: { setup_complete: false } } as never, + const addCamera = await installFirstRun(frigateApp, page, { hardware: NVIDIA_HARDWARE, }); const saves = await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page); + await gotoDetectorStep(page, addCamera); await expect( page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }), @@ -176,22 +202,16 @@ test.describe("setup wizard hardware @high @mobile", () => { ).toBeHidden(); await page.getByRole("button", { name: "Go to Live View" }).click(); - // the mocked config still reports onboarding incomplete, so the reload - // lands back on the wizard - await expect(page.getByText("Welcome to Frigate")).toBeVisible(); + // hands off without restarting, and the wizard does not come back + await expect(page.getByText("Welcome to Frigate")).toBeHidden(); expect(restarts).toHaveLength(0); - const finishSave = saves.find((save) => save.config_data?.onboarding); - expect(finishSave?.config_data?.onboarding).toEqual({ - setup_complete: true, - }); }); test("offers only the presets the hardware supports", async ({ frigateApp, page, }) => { - await frigateApp.installDefaults({ - config: { onboarding: { setup_complete: false } } as never, + const addCamera = await installFirstRun(frigateApp, page, { hardware: NVIDIA_HARDWARE, hwaccel: { recommended: "nvidia", @@ -201,7 +221,7 @@ test.describe("setup wizard hardware @high @mobile", () => { await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page); + await gotoDetectorStep(page, addCamera); await page .getByRole("button", { name: "Continue without detection" }) .click(); @@ -227,8 +247,7 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - await frigateApp.installDefaults({ - config: { onboarding: { setup_complete: false } } as never, + const addCamera = await installFirstRun(frigateApp, page, { hwaccel: { recommended: "jetson", available: [ @@ -245,7 +264,7 @@ test.describe("setup wizard hardware @high @mobile", () => { const saves = await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page); + await gotoDetectorStep(page, addCamera); await page.getByRole("button", { name: "Next" }).click(); await expect(page.getByText("Hardware Acceleration")).toBeVisible(); @@ -265,13 +284,11 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - await frigateApp.installDefaults({ - config: { onboarding: { setup_complete: false } } as never, - }); + const addCamera = await installFirstRun(frigateApp, page); const saves = await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page); + await gotoDetectorStep(page, addCamera); await page.getByRole("button", { name: "Next" }).click(); await expect(page.getByText("Hardware Acceleration")).toBeVisible(); diff --git a/web/src/App.tsx b/web/src/App.tsx index dba3c97b10..0ca113251f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,7 +6,7 @@ import Sidebar from "@/components/navigation/Sidebar"; import { isDesktop, isMobile } from "react-device-detect"; import Statusbar from "./components/Statusbar"; import Bottombar from "./components/navigation/Bottombar"; -import { Suspense, lazy } from "react"; +import { Suspense, lazy, useEffect, useState } from "react"; import { Redirect } from "./components/navigation/Redirect"; import { cn } from "./lib/utils"; import { isPWA } from "./utils/isPWA"; @@ -15,6 +15,7 @@ import useSWR from "swr"; import { FrigateConfig } from "./types/frigateConfig"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { isRedirectingToLogin } from "@/api/auth-redirect"; +import { isSetupDismissed } from "@/utils/setupWizard"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -53,6 +54,18 @@ function DefaultAppView() { revalidateOnFocus: false, }); + // decided once per load: adding the first camera part way through the + // wizard must not pull the wizard out from under the user + const [showWizard, setShowWizard] = useState(); + + useEffect(() => { + if (config && showWizard === undefined) { + setShowWizard( + Object.keys(config.cameras ?? {}).length === 0 && !isSetupDismissed(), + ); + } + }, [config, showWizard]); + // Compute required roles for main routes, ensuring we have config first // to prevent race condition where custom roles are temporarily unavailable const mainRouteRoles = config?.auth?.roles @@ -70,7 +83,7 @@ function DefaultAppView() { } // Show setup wizard for first-time users - if (config && config.onboarding?.setup_complete === false) { + if (showWizard) { return (
{ setFinishing(true); - try { - await axios.put("config/set", { - config_data: { - onboarding: { setup_complete: true }, - }, - requires_restart: 0, - }); + dismissSetup(); + try { // camera adds were applied live, so nothing is waiting on a restart if (!restartRequired) { window.location.href = window.baseUrl || "/"; diff --git a/web/src/pages/SetupWizard.tsx b/web/src/pages/SetupWizard.tsx index a2edefbae5..53460d631d 100644 --- a/web/src/pages/SetupWizard.tsx +++ b/web/src/pages/SetupWizard.tsx @@ -8,11 +8,10 @@ import SetupWelcome from "@/components/setup/SetupWelcome"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { useTheme } from "@/context/theme-provider"; +import { dismissSetup } from "@/utils/setupWizard"; import { useCallback, useReducer } from "react"; import { useTranslation } from "react-i18next"; import { LuMoon, LuSun } from "react-icons/lu"; -import { toast } from "sonner"; -import axios from "axios"; const STEPS = [ "setupWizard.steps.welcome", @@ -117,19 +116,10 @@ export default function SetupWizard() { const isDark = (theme === "system" ? systemTheme : theme) === "dark"; - 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 handleSkipSetup = useCallback(() => { + dismissSetup(); + window.location.href = window.baseUrl || "/"; + }, []); const handleCameraNext = useCallback( (cameraNames?: string[], detectCodecs?: Record) => { diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 452443f3cf..fcb6a9aace 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -657,9 +657,5 @@ export interface FrigateConfig { thickness: number; }; - onboarding: { - setup_complete: boolean; - }; - ui: UiConfig; } diff --git a/web/src/utils/setupWizard.ts b/web/src/utils/setupWizard.ts new file mode 100644 index 0000000000..50e6734b59 --- /dev/null +++ b/web/src/utils/setupWizard.ts @@ -0,0 +1,19 @@ +// dismissing the setup wizard is per-device UI state, so it lives in the +// browser rather than in the config the wizard exists to write +const DISMISSED_KEY = "frigate-setup-dismissed"; + +export function isSetupDismissed(): boolean { + try { + return localStorage.getItem(DISMISSED_KEY) === "true"; + } catch { + return false; + } +} + +export function dismissSetup(): void { + try { + localStorage.setItem(DISMISSED_KEY, "true"); + } catch { + // storage can be unavailable; showing the wizard again beats failing here + } +}