Files
frigate/web/src/pages/SetupWizard.tsx
T
Josh HawkinsandNicolas Mowen fb8ab56c41 Add onboarding wizard for new installations (#24102)
* add onboarding wizard for new users

* resolve hwaccel per camera and clarify recording retention

The hwaccel step listed every preset Frigate ships, so an Intel box was offered Raspberry Pi and Rockchip decoding, and the codec specific presets (`preset-intel-qsv-h264` vs `-h265`) were offered as global values that break as soon as two cameras use different codecs. `/hardware/hwaccel` now returns the decoding families the probed hardware can actually use, each carrying a preset per codec, and the wizard resolves the family against the detect stream codec the camera wizard already probed: one global `ffmpeg.hwaccel_args` when every camera agrees, per-camera `cameras.<name>.ffmpeg.hwaccel_args` when they don't. The global stays on `auto` in that case so cameras added later still resolve at startup. A gen13+ Intel machine keeps its QuickSync recommendation with mixed h264 and h265 cameras instead of dropping to vaapi.

The recording step's "Days to retain recordings" only wrote alert and detection retention, and the storage estimate under it assumed continuous recording. It now asks what to record in plain language, writes `record.continuous.days` to match, shows the estimate only for continuous, and drops the spinner arrows on the number input.

* clean up

* add light/dark mode icon switcher

* use yml as default config file extension when not found

* i18n tweaks

* gate the setup wizard on cameras instead of a config key

* render setup wizard steps by key

* share the setup wizard e2e helpers and mock users

* add an account step to the setup wizard

* add setup wizard account step e2e coverage

* cover the account step's restart behavior

* button consistency

* fix test

* docs

* fixes
2026-09-12 07:30:04 -06:00

285 lines
8.1 KiB
TypeScript

import StepIndicator from "@/components/indicators/StepIndicator";
import SetupAccount from "@/components/setup/SetupAccount";
import SetupCamera from "@/components/setup/SetupCamera";
import SetupComplete from "@/components/setup/SetupComplete";
import SetupDetector from "@/components/setup/SetupDetector";
import SetupHwAccel from "@/components/setup/SetupHwAccel";
import SetupRecording from "@/components/setup/SetupRecording";
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 { FrigateConfig } from "@/types/frigateConfig";
import { dismissSetup } from "@/utils/setupWizard";
import { useCallback, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import { LuMoon, LuSun } from "react-icons/lu";
import useSWR from "swr";
type StepKey =
| "welcome"
| "account"
| "camera"
| "detector"
| "hwaccel"
| "recording"
| "complete";
const STEP_KEYS: StepKey[] = [
"welcome",
"account",
"camera",
"detector",
"hwaccel",
"recording",
"complete",
];
type WizardState = {
currentStep: number;
cameraNames: string[];
detectorHardwareKey?: string;
// camera name -> detect stream codec
detectCodecs: Record<string, string>;
// camera adds apply live, so they don't count toward needing a restart
restartRequired: boolean;
configuredSteps: {
camera: boolean;
hwaccel: boolean;
detector: boolean;
recording: boolean;
};
};
type WizardAction =
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| {
type: "CAMERAS_ADDED";
cameraNames: string[];
detectCodecs: Record<string, string>;
}
| {
type: "STEP_CONFIGURED";
step: keyof WizardState["configuredSteps"];
savedConfig: boolean;
}
| { type: "DETECTOR_DONE"; configured: boolean; hardwareKey?: string }
| { type: "SKIP_STEP" };
const initialState: WizardState = {
currentStep: 0,
cameraNames: [],
detectCodecs: {},
restartRequired: false,
configuredSteps: {
camera: false,
hwaccel: false,
detector: false,
recording: false,
},
};
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "NEXT_STEP":
return { ...state, currentStep: state.currentStep + 1 };
case "PREV_STEP":
return {
...state,
currentStep: Math.max(0, state.currentStep - 1),
};
case "CAMERAS_ADDED":
return {
...state,
currentStep: state.currentStep + 1,
cameraNames: action.cameraNames,
detectCodecs: action.detectCodecs,
configuredSteps: { ...state.configuredSteps, camera: true },
};
case "STEP_CONFIGURED":
return {
...state,
currentStep: state.currentStep + 1,
restartRequired: state.restartRequired || action.savedConfig,
configuredSteps: { ...state.configuredSteps, [action.step]: true },
};
case "DETECTOR_DONE":
return {
...state,
currentStep: state.currentStep + 1,
detectorHardwareKey: action.hardwareKey ?? state.detectorHardwareKey,
restartRequired: state.restartRequired || action.configured,
configuredSteps: {
...state.configuredSteps,
detector: state.configuredSteps.detector || action.configured,
},
};
case "SKIP_STEP":
return { ...state, currentStep: state.currentStep + 1 };
default:
return state;
}
}
export default function SetupWizard() {
const { t } = useTranslation(["views/setup", "common"]);
const [state, dispatch] = useReducer(wizardReducer, initialState);
const { theme, systemTheme, setTheme } = useTheme();
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
// with native auth off there are no users to manage, so the step would lie
const steps = useMemo(
() =>
config?.auth?.enabled === false
? STEP_KEYS.filter((key) => key !== "account")
: STEP_KEYS,
[config],
);
const stepLabels = useMemo(
() => steps.map((key) => `setupWizard.steps.${key}`),
[steps],
);
const isDark = (theme === "system" ? systemTheme : theme) === "dark";
const handleSkipSetup = useCallback(() => {
dismissSetup();
window.location.href = window.baseUrl || "/";
}, []);
const handleCameraNext = useCallback(
(cameraNames?: string[], detectCodecs?: Record<string, string>) => {
if (cameraNames && cameraNames.length > 0) {
dispatch({
type: "CAMERAS_ADDED",
cameraNames,
detectCodecs: detectCodecs ?? {},
});
} else {
dispatch({ type: "SKIP_STEP" });
}
},
[],
);
const handleHwAccelNext = useCallback((saved: boolean) => {
dispatch({ type: "STEP_CONFIGURED", step: "hwaccel", savedConfig: saved });
}, []);
const handleDetectorNext = useCallback((hardwareKey: string) => {
dispatch({ type: "DETECTOR_DONE", configured: true, hardwareKey });
}, []);
const handleDetectorSkip = useCallback((hardwareKey?: string) => {
dispatch({ type: "DETECTOR_DONE", configured: false, hardwareKey });
}, []);
const handleRecordingNext = useCallback(() => {
dispatch({ type: "STEP_CONFIGURED", step: "recording", savedConfig: true });
}, []);
const handleBack = useCallback(() => {
dispatch({ type: "PREV_STEP" });
}, []);
const handleSkipStep = useCallback(() => {
dispatch({ type: "SKIP_STEP" });
}, []);
const renderStep = () => {
switch (steps[state.currentStep]) {
case "welcome":
return (
<SetupWelcome
onNext={() => dispatch({ type: "NEXT_STEP" })}
onSkip={handleSkipSetup}
/>
);
case "account":
return (
<SetupAccount
onNext={handleSkipStep}
onBack={handleBack}
onSkip={handleSkipStep}
/>
);
case "camera":
return <SetupCamera onNext={handleCameraNext} onBack={handleBack} />;
case "detector":
return (
<SetupDetector
cameraCount={state.cameraNames.length}
onNext={handleDetectorNext}
onBack={handleBack}
onSkip={handleDetectorSkip}
/>
);
case "hwaccel":
return (
<SetupHwAccel
detectorHardwareKey={state.detectorHardwareKey}
detectCodecs={state.detectCodecs}
onNext={handleHwAccelNext}
onBack={handleBack}
onSkip={handleSkipStep}
/>
);
case "recording":
return (
<SetupRecording
cameraNames={state.cameraNames}
onNext={handleRecordingNext}
onBack={handleBack}
onSkip={handleSkipStep}
/>
);
case "complete":
return (
<SetupComplete
cameraNames={state.cameraNames}
configuredSteps={state.configuredSteps}
restartRequired={state.restartRequired}
onBack={handleBack}
/>
);
default:
return null;
}
};
return (
<div className="flex min-h-dvh items-center justify-center bg-background p-4">
<Button
type="button"
variant="ghost"
size="icon"
className="fixed right-4 top-4 text-muted-foreground hover:text-primary"
aria-label={t(isDark ? "menu.darkMode.light" : "menu.darkMode.dark", {
ns: "common",
})}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <LuSun className="size-4" /> : <LuMoon className="size-4" />}
</Button>
<Card className="w-full max-w-lg bg-background_alt">
<CardContent className="p-6">
<StepIndicator
steps={stepLabels}
currentStep={state.currentStep}
variant="dots"
translationNameSpace="views/setup"
className="mb-4 justify-start"
/>
<div className="fade-in">{renderStep()}</div>
</CardContent>
</Card>
</div>
);
}