gate the setup wizard on cameras instead of a config key

This commit is contained in:
Josh Hawkins
2026-08-22 11:51:08 -05:00
parent ff71e823c8
commit f25d785380
9 changed files with 85 additions and 82 deletions
-7
View File
@@ -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",
-13
View File
@@ -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.",
)
-8
View File
@@ -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
+43 -26
View File
@@ -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<string[]> {
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<void> },
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();
+15 -2
View File
@@ -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<boolean>();
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 (
<div className="size-full overflow-hidden">
<Suspense
+3 -7
View File
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import axios from "axios";
import { FaCircleCheck } from "react-icons/fa6";
import { dismissSetup } from "@/utils/setupWizard";
type ConfiguredItem = {
key: string;
@@ -86,14 +87,9 @@ export default function SetupComplete({
const handleFinish = useCallback(async () => {
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 || "/";
+5 -15
View File
@@ -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<string, string>) => {
-4
View File
@@ -657,9 +657,5 @@ export interface FrigateConfig {
thickness: number;
};
onboarding: {
setup_complete: boolean;
};
ui: UiConfig;
}
+19
View File
@@ -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
}
}