mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-29 11:26:49 +03:00
gate the setup wizard on cameras instead of a config key
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 || "/";
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -657,9 +657,5 @@ export interface FrigateConfig {
|
||||
thickness: number;
|
||||
};
|
||||
|
||||
onboarding: {
|
||||
setup_complete: boolean;
|
||||
};
|
||||
|
||||
ui: UiConfig;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user