mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-10 08:42: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 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
|
||||||
@@ -637,12 +636,6 @@ 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",
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
import { test, expect } from "../../fixtures/frigate-test";
|
import { test, expect } from "../../fixtures/frigate-test";
|
||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
import { configFactory } from "../../fixtures/mock-data/config";
|
||||||
|
import type { ApiMockOverrides } from "../../helpers/api-mocker";
|
||||||
|
|
||||||
const NVIDIA_HARDWARE = [
|
const NVIDIA_HARDWARE = [
|
||||||
{
|
{
|
||||||
@@ -34,7 +36,6 @@ type SavedConfig = {
|
|||||||
models?: { devices: string[]; path?: string }[];
|
models?: { devices: string[]; path?: string }[];
|
||||||
detect?: { enabled?: boolean };
|
detect?: { enabled?: boolean };
|
||||||
ffmpeg?: { hwaccel_args?: string | string[] };
|
ffmpeg?: { hwaccel_args?: string | string[] };
|
||||||
onboarding?: { setup_complete?: boolean };
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +57,34 @@ async function captureRestarts(page: Page): Promise<string[]> {
|
|||||||
return calls;
|
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 page.getByRole("button", { name: "Get Started" }).click();
|
||||||
await expect(page.getByText("Add Your First Camera")).toBeVisible();
|
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
|
// which SetupCamera does by refetching config when the dialog closes, so
|
||||||
// opening and cancelling the dialog is what reveals Next
|
// opening and cancelling the dialog is what reveals Next
|
||||||
await page.getByRole("button", { name: "Add Camera" }).click();
|
await page.getByRole("button", { name: "Add Camera" }).click();
|
||||||
|
addCamera();
|
||||||
await page.getByRole("button", { name: "Cancel" }).click();
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Next" })).toBeVisible({
|
await expect(page.getByRole("button", { name: "Next" })).toBeVisible({
|
||||||
@@ -78,9 +107,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.installDefaults({
|
const addCamera = await installFirstRun(frigateApp, page, {
|
||||||
// the base config snapshot predates the onboarding key, hence the cast
|
|
||||||
config: { onboarding: { setup_complete: false } } as never,
|
|
||||||
hwaccel: {
|
hwaccel: {
|
||||||
recommended: "vaapi",
|
recommended: "vaapi",
|
||||||
available: [
|
available: [
|
||||||
@@ -98,7 +125,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
const saves = await captureSaves(page);
|
const saves = await captureSaves(page);
|
||||||
|
|
||||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
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
|
// the default hardware mock reports two Corals, an Intel GPU, and the CPU
|
||||||
await expect(
|
await expect(
|
||||||
@@ -143,14 +170,13 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.installDefaults({
|
const addCamera = await installFirstRun(frigateApp, page, {
|
||||||
config: { onboarding: { setup_complete: false } } as never,
|
|
||||||
hardware: NVIDIA_HARDWARE,
|
hardware: NVIDIA_HARDWARE,
|
||||||
});
|
});
|
||||||
const saves = await captureSaves(page);
|
const saves = await captureSaves(page);
|
||||||
|
|
||||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||||
await gotoDetectorStep(page);
|
await gotoDetectorStep(page, addCamera);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }),
|
page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }),
|
||||||
@@ -176,22 +202,16 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
).toBeHidden();
|
).toBeHidden();
|
||||||
await page.getByRole("button", { name: "Go to Live View" }).click();
|
await page.getByRole("button", { name: "Go to Live View" }).click();
|
||||||
|
|
||||||
// the mocked config still reports onboarding incomplete, so the reload
|
// hands off without restarting, and the wizard does not come back
|
||||||
// lands back on the wizard
|
await expect(page.getByText("Welcome to Frigate")).toBeHidden();
|
||||||
await expect(page.getByText("Welcome to Frigate")).toBeVisible();
|
|
||||||
expect(restarts).toHaveLength(0);
|
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 ({
|
test("offers only the presets the hardware supports", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.installDefaults({
|
const addCamera = await installFirstRun(frigateApp, page, {
|
||||||
config: { onboarding: { setup_complete: false } } as never,
|
|
||||||
hardware: NVIDIA_HARDWARE,
|
hardware: NVIDIA_HARDWARE,
|
||||||
hwaccel: {
|
hwaccel: {
|
||||||
recommended: "nvidia",
|
recommended: "nvidia",
|
||||||
@@ -201,7 +221,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
await captureSaves(page);
|
await captureSaves(page);
|
||||||
|
|
||||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||||
await gotoDetectorStep(page);
|
await gotoDetectorStep(page, addCamera);
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "Continue without detection" })
|
.getByRole("button", { name: "Continue without detection" })
|
||||||
.click();
|
.click();
|
||||||
@@ -227,8 +247,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.installDefaults({
|
const addCamera = await installFirstRun(frigateApp, page, {
|
||||||
config: { onboarding: { setup_complete: false } } as never,
|
|
||||||
hwaccel: {
|
hwaccel: {
|
||||||
recommended: "jetson",
|
recommended: "jetson",
|
||||||
available: [
|
available: [
|
||||||
@@ -245,7 +264,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
const saves = await captureSaves(page);
|
const saves = await captureSaves(page);
|
||||||
|
|
||||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||||
await gotoDetectorStep(page);
|
await gotoDetectorStep(page, addCamera);
|
||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||||
|
|
||||||
@@ -265,13 +284,11 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.installDefaults({
|
const addCamera = await installFirstRun(frigateApp, page);
|
||||||
config: { onboarding: { setup_complete: false } } as never,
|
|
||||||
});
|
|
||||||
const saves = await captureSaves(page);
|
const saves = await captureSaves(page);
|
||||||
|
|
||||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||||
await gotoDetectorStep(page);
|
await gotoDetectorStep(page, addCamera);
|
||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
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 { isDesktop, isMobile } from "react-device-detect";
|
||||||
import Statusbar from "./components/Statusbar";
|
import Statusbar from "./components/Statusbar";
|
||||||
import Bottombar from "./components/navigation/Bottombar";
|
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 { Redirect } from "./components/navigation/Redirect";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
import { isPWA } from "./utils/isPWA";
|
import { isPWA } from "./utils/isPWA";
|
||||||
@@ -15,6 +15,7 @@ import useSWR from "swr";
|
|||||||
import { FrigateConfig } from "./types/frigateConfig";
|
import { FrigateConfig } from "./types/frigateConfig";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { isRedirectingToLogin } from "@/api/auth-redirect";
|
import { isRedirectingToLogin } from "@/api/auth-redirect";
|
||||||
|
import { isSetupDismissed } from "@/utils/setupWizard";
|
||||||
|
|
||||||
const Live = lazy(() => import("@/pages/Live"));
|
const Live = lazy(() => import("@/pages/Live"));
|
||||||
const Events = lazy(() => import("@/pages/Events"));
|
const Events = lazy(() => import("@/pages/Events"));
|
||||||
@@ -53,6 +54,18 @@ function DefaultAppView() {
|
|||||||
revalidateOnFocus: false,
|
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
|
// Compute required roles for main routes, ensuring we have config first
|
||||||
// to prevent race condition where custom roles are temporarily unavailable
|
// to prevent race condition where custom roles are temporarily unavailable
|
||||||
const mainRouteRoles = config?.auth?.roles
|
const mainRouteRoles = config?.auth?.roles
|
||||||
@@ -70,7 +83,7 @@ function DefaultAppView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show setup wizard for first-time users
|
// Show setup wizard for first-time users
|
||||||
if (config && config.onboarding?.setup_complete === false) {
|
if (showWizard) {
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
<Suspense
|
<Suspense
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { FaCircleCheck } from "react-icons/fa6";
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
|
import { dismissSetup } from "@/utils/setupWizard";
|
||||||
|
|
||||||
type ConfiguredItem = {
|
type ConfiguredItem = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -86,14 +87,9 @@ export default function SetupComplete({
|
|||||||
|
|
||||||
const handleFinish = useCallback(async () => {
|
const handleFinish = useCallback(async () => {
|
||||||
setFinishing(true);
|
setFinishing(true);
|
||||||
try {
|
dismissSetup();
|
||||||
await axios.put("config/set", {
|
|
||||||
config_data: {
|
|
||||||
onboarding: { setup_complete: true },
|
|
||||||
},
|
|
||||||
requires_restart: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
try {
|
||||||
// camera adds were applied live, so nothing is waiting on a restart
|
// camera adds were applied live, so nothing is waiting on a restart
|
||||||
if (!restartRequired) {
|
if (!restartRequired) {
|
||||||
window.location.href = window.baseUrl || "/";
|
window.location.href = window.baseUrl || "/";
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import SetupWelcome from "@/components/setup/SetupWelcome";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { useTheme } from "@/context/theme-provider";
|
import { useTheme } from "@/context/theme-provider";
|
||||||
|
import { dismissSetup } from "@/utils/setupWizard";
|
||||||
import { useCallback, useReducer } from "react";
|
import { useCallback, useReducer } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuMoon, LuSun } from "react-icons/lu";
|
import { LuMoon, LuSun } from "react-icons/lu";
|
||||||
import { toast } from "sonner";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
"setupWizard.steps.welcome",
|
"setupWizard.steps.welcome",
|
||||||
@@ -117,19 +116,10 @@ export default function SetupWizard() {
|
|||||||
|
|
||||||
const isDark = (theme === "system" ? systemTheme : theme) === "dark";
|
const isDark = (theme === "system" ? systemTheme : theme) === "dark";
|
||||||
|
|
||||||
const handleSkipSetup = useCallback(async () => {
|
const handleSkipSetup = useCallback(() => {
|
||||||
try {
|
dismissSetup();
|
||||||
await axios.put("config/set", {
|
window.location.href = window.baseUrl || "/";
|
||||||
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(
|
const handleCameraNext = useCallback(
|
||||||
(cameraNames?: string[], detectCodecs?: Record<string, string>) => {
|
(cameraNames?: string[], detectCodecs?: Record<string, string>) => {
|
||||||
|
|||||||
@@ -657,9 +657,5 @@ export interface FrigateConfig {
|
|||||||
thickness: number;
|
thickness: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
onboarding: {
|
|
||||||
setup_complete: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
ui: UiConfig;
|
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