diff --git a/web/e2e/helpers/setup-wizard.ts b/web/e2e/helpers/setup-wizard.ts index 9013465ab8..41cffdece4 100644 --- a/web/e2e/helpers/setup-wizard.ts +++ b/web/e2e/helpers/setup-wizard.ts @@ -2,7 +2,9 @@ * Shared setup-wizard e2e helpers. * * The wizard shows when config has no cameras, so a first run is mocked by - * serving a camera-less config until the returned callback is fired. + * serving a camera-less config until the returned callback is fired. Firing + * it is only needed by tests that care what the rest of the app sees; the + * wizard itself tracks added cameras from the camera dialog's own callback. */ import type { Page } from "@playwright/test"; @@ -32,7 +34,7 @@ export async function installFirstRun( }; } -export async function gotoDetectorStep(page: Page, addCamera: () => void) { +export async function gotoDetectorStep(page: Page) { await page.getByRole("button", { name: "Get Started" }).click(); // the account step sits between welcome and camera whenever auth is on, @@ -43,17 +45,7 @@ export async function gotoDetectorStep(page: Page, addCamera: () => void) { await page.getByRole("button", { name: "Skip" }).click(); await expect(page.getByText("Add Your First Camera")).toBeVisible(); + await page.getByRole("button", { name: "Skip" }).click(); - // the camera step's Next only renders once its local addedCameras fills, - // 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({ - timeout: 10_000, - }); - await page.getByRole("button", { name: "Next" }).click(); await expect(page.getByText("Object Detection")).toBeVisible(); } diff --git a/web/e2e/specs/setup/wizard-account.spec.ts b/web/e2e/specs/setup/wizard-account.spec.ts index dcfbe1b0fd..df5e08995c 100644 --- a/web/e2e/specs/setup/wizard-account.spec.ts +++ b/web/e2e/specs/setup/wizard-account.spec.ts @@ -155,7 +155,7 @@ test.describe("setup wizard account @high @mobile", () => { frigateApp, page, }) => { - const addCamera = await installFirstRun(frigateApp, page); + await installFirstRun(frigateApp, page); await captureUserCalls(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); @@ -173,16 +173,8 @@ test.describe("setup wizard account @high @mobile", () => { await expect(page.getByText("Password set")).toBeVisible(); await page.getByRole("button", { name: "Next" }).click(); - // the camera step has no Skip, so it is advanced the way the detector - // helper does, by opening and cancelling the add dialog await expect(page.getByText("Add Your First Camera")).toBeVisible(); - await page.getByRole("button", { name: "Add Camera" }).click(); - addCamera(); - 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 page.getByRole("button", { name: "Skip" }).click(); // every remaining step is passed without writing config: Skip on the // detector, then Auto on hwaccel, which has nothing to derive and so diff --git a/web/e2e/specs/setup/wizard-hardware.spec.ts b/web/e2e/specs/setup/wizard-hardware.spec.ts index 57d0e43b3d..a4b57c4444 100644 --- a/web/e2e/specs/setup/wizard-hardware.spec.ts +++ b/web/e2e/specs/setup/wizard-hardware.spec.ts @@ -61,7 +61,7 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - const addCamera = await installFirstRun(frigateApp, page, { + await installFirstRun(frigateApp, page, { hwaccel: { recommended: "vaapi", available: [ @@ -79,7 +79,7 @@ test.describe("setup wizard hardware @high @mobile", () => { const saves = await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page, addCamera); + await gotoDetectorStep(page); // the default hardware mock reports two Corals, an Intel GPU, and the CPU await expect( @@ -124,13 +124,13 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - const addCamera = await installFirstRun(frigateApp, page, { + await installFirstRun(frigateApp, page, { hardware: NVIDIA_HARDWARE, }); const saves = await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page, addCamera); + await gotoDetectorStep(page); await expect( page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }), @@ -165,7 +165,7 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - const addCamera = await installFirstRun(frigateApp, page, { + await installFirstRun(frigateApp, page, { hardware: NVIDIA_HARDWARE, hwaccel: { recommended: "nvidia", @@ -175,7 +175,7 @@ test.describe("setup wizard hardware @high @mobile", () => { await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page, addCamera); + await gotoDetectorStep(page); await page .getByRole("button", { name: "Continue without detection" }) .click(); @@ -201,7 +201,7 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - const addCamera = await installFirstRun(frigateApp, page, { + await installFirstRun(frigateApp, page, { hwaccel: { recommended: "jetson", available: [ @@ -218,7 +218,7 @@ test.describe("setup wizard hardware @high @mobile", () => { const saves = await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page, addCamera); + await gotoDetectorStep(page); await page.getByRole("button", { name: "Next" }).click(); await expect(page.getByText("Hardware Acceleration")).toBeVisible(); @@ -238,11 +238,11 @@ test.describe("setup wizard hardware @high @mobile", () => { frigateApp, page, }) => { - const addCamera = await installFirstRun(frigateApp, page); + await installFirstRun(frigateApp, page); const saves = await captureSaves(page); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); - await gotoDetectorStep(page, addCamera); + await gotoDetectorStep(page); 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 0ca113251f..f6d43188ff 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, useEffect, useState } from "react"; +import { Suspense, lazy, useContext, useEffect, useState } from "react"; import { Redirect } from "./components/navigation/Redirect"; import { cn } from "./lib/utils"; import { isPWA } from "./utils/isPWA"; @@ -15,6 +15,8 @@ import useSWR from "swr"; import { FrigateConfig } from "./types/frigateConfig"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { isRedirectingToLogin } from "@/api/auth-redirect"; +import { AuthContext } from "@/context/auth-context"; +import { useIsAdmin } from "@/hooks/use-is-admin"; import { isSetupDismissed } from "@/utils/setupWizard"; const Live = lazy(() => import("@/pages/Live")); @@ -57,14 +59,20 @@ function DefaultAppView() { // 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(); + const { auth } = useContext(AuthContext); + const isAdmin = useIsAdmin(); useEffect(() => { - if (config && showWizard === undefined) { + // every step writes through admin only endpoints, and the role isn't + // known until the profile resolves + if (config && !auth.isLoading && showWizard === undefined) { setShowWizard( - Object.keys(config.cameras ?? {}).length === 0 && !isSetupDismissed(), + isAdmin && + Object.keys(config.cameras ?? {}).length === 0 && + !isSetupDismissed(), ); } - }, [config, showWizard]); + }, [config, auth.isLoading, isAdmin, showWizard]); // Compute required roles for main routes, ensuring we have config first // to prevent race condition where custom roles are temporarily unavailable diff --git a/web/src/components/setup/SetupCamera.tsx b/web/src/components/setup/SetupCamera.tsx index b87d989245..0276d62427 100644 --- a/web/src/components/setup/SetupCamera.tsx +++ b/web/src/components/setup/SetupCamera.tsx @@ -2,8 +2,6 @@ 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 = { @@ -19,30 +17,18 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) { const [showWizard, setShowWizard] = useState(false); const [addedCameras, setAddedCameras] = useState([]); const [detectCodecs, setDetectCodecs] = useState>({}); - const { mutate: mutateConfig } = useSWR("config", { - revalidateOnFocus: false, - }); - const handleClose = useCallback(() => { setShowWizard(false); - // the dialog's config save and go2rtc setup need a moment to land, and - // the SWR cache would serve the pre-save config - 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(() => {}); - }, 1000); - }, [mutateConfig, addedCameras]); + }, []); + // the dialog fires this once its config write has succeeded, which is the + // only reliable signal that a camera was added const handleCameraAdded = useCallback( ({ name, detectCodec }: { name: string; detectCodec?: string }) => { + setAddedCameras((previous) => + previous.includes(name) ? previous : [...previous, name], + ); + if (detectCodec) { setDetectCodecs((previous) => ({ ...previous, [name]: detectCodec })); } @@ -51,9 +37,13 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) { ); const handleNext = useCallback(() => { - onNext(addedCameras.length > 0 ? addedCameras : undefined, detectCodecs); + onNext(addedCameras, detectCodecs); }, [onNext, addedCameras, detectCodecs]); + const handleSkip = useCallback(() => { + onNext(); + }, [onNext]); + return ( <>
@@ -97,10 +87,14 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) { {t("setupWizard.actions.back")}
- {addedCameras.length > 0 && ( + {addedCameras.length > 0 ? ( + ) : ( + )}