mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 06:18:58 +03:00
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
This commit is contained in:
committed by
Nicolas Mowen
parent
96013a0487
commit
fb8ab56c41
@@ -43,6 +43,11 @@ export interface ApiMockOverrides {
|
||||
configRaw?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
hardware?: unknown[];
|
||||
hwaccel?: {
|
||||
recommended: string;
|
||||
available?: { key: string; presets: Record<string, string> }[];
|
||||
};
|
||||
users?: { username: string; role: string }[];
|
||||
}
|
||||
|
||||
export class ApiMocker {
|
||||
@@ -185,6 +190,27 @@ export class ApiMocker {
|
||||
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
|
||||
);
|
||||
|
||||
// Hwaccel preset recommendation
|
||||
await this.page.route("**/api/hardware/hwaccel**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
recommended: "",
|
||||
available: [],
|
||||
...(overrides?.hwaccel ?? {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Users. GET lists them; POST/PUT (create, password) just succeed, so
|
||||
// tests assert on the intercepted request body instead of a response.
|
||||
await this.page.route("**/api/users**", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? route.fulfill({
|
||||
json: overrides?.users ?? [{ username: "admin", role: "admin" }],
|
||||
})
|
||||
: route.fulfill({ json: { message: "ok" } }),
|
||||
);
|
||||
|
||||
// Go2RTC streams
|
||||
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
||||
route.fulfill({ json: {} }),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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. 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";
|
||||
import { expect } from "../fixtures/frigate-test";
|
||||
import { configFactory } from "../fixtures/mock-data/config";
|
||||
import type { ApiMockOverrides } from "./api-mocker";
|
||||
|
||||
export 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;
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
// which the default mock config has it
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Secure your account" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
await expect(page.getByText("Add Your First Camera")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
await expect(page.getByText("Object Detection")).toBeVisible();
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Setup wizard account step -- HIGH tier.
|
||||
*
|
||||
* Covers the step's placement and gating, the password and user payloads it
|
||||
* sends, the copy it shows when nobody is signed in (the internal port), and
|
||||
* that skipping it writes nothing.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { installFirstRun } from "../../helpers/setup-wizard";
|
||||
|
||||
type Sent = {
|
||||
method: string;
|
||||
url: string;
|
||||
body: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function captureUserCalls(page: Page): Promise<Sent[]> {
|
||||
const sent: Sent[] = [];
|
||||
|
||||
await page.route("**/api/users**", (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() === "GET") {
|
||||
return route.fulfill({ json: [{ username: "admin", role: "admin" }] });
|
||||
}
|
||||
|
||||
sent.push({
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
body: request.postDataJSON(),
|
||||
});
|
||||
return route.fulfill({ json: { message: "ok" } });
|
||||
});
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
async function gotoAccountStep(page: Page) {
|
||||
await page.getByRole("button", { name: "Get Started" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Secure your account" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("setup wizard account @high @mobile", () => {
|
||||
test("sets the admin password without an old password", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page);
|
||||
const sent = await captureUserCalls(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoAccountStep(page);
|
||||
|
||||
await page.getByRole("button", { name: "Change password" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
// the dialog is in set-password mode, so it asks for no current password
|
||||
await expect(
|
||||
dialog.getByPlaceholder("Enter your current password"),
|
||||
).toBeHidden();
|
||||
await dialog
|
||||
.getByPlaceholder("Enter new password", { exact: true })
|
||||
.fill("a-long-enough-password");
|
||||
await dialog
|
||||
.getByPlaceholder("Re-enter new password")
|
||||
.fill("a-long-enough-password");
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect(page.getByText("Password set")).toBeVisible();
|
||||
|
||||
const passwordCall = sent.find((call) => call.method === "PUT");
|
||||
expect(passwordCall?.url).toContain("/users/admin/password");
|
||||
// admins are exempt from the current-password check, so it must not be sent
|
||||
expect(passwordCall?.body).toEqual({ password: "a-long-enough-password" });
|
||||
});
|
||||
|
||||
test("creates a user with a role", async ({ frigateApp, page }) => {
|
||||
await installFirstRun(frigateApp, page);
|
||||
const sent = await captureUserCalls(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoAccountStep(page);
|
||||
|
||||
await page.getByRole("button", { name: "Add user" }).click();
|
||||
|
||||
await page.getByPlaceholder("Enter username").fill("family");
|
||||
await page
|
||||
.getByPlaceholder("Enter password")
|
||||
.fill("a-long-enough-password");
|
||||
await page
|
||||
.getByPlaceholder("Confirm Password")
|
||||
.fill("a-long-enough-password");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
const createCall = sent.find((call) => call.method === "POST");
|
||||
expect(createCall?.body).toEqual({
|
||||
username: "family",
|
||||
password: "a-long-enough-password",
|
||||
role: "viewer",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows anonymous copy when nobody is signed in", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page, {
|
||||
profile: { username: "anonymous", role: "admin", allowed_cameras: null },
|
||||
});
|
||||
await captureUserCalls(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoAccountStep(page);
|
||||
|
||||
await expect(page.getByText("doesn't require a login")).toBeVisible();
|
||||
await expect(page.getByText("You're signed in as")).toBeHidden();
|
||||
});
|
||||
|
||||
test("is absent when native auth is disabled", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page, {
|
||||
config: { auth: { enabled: false } } as never,
|
||||
});
|
||||
await captureUserCalls(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await page.getByRole("button", { name: "Get Started" }).click();
|
||||
|
||||
// straight from welcome to the camera step, with no gap in the indicator
|
||||
await expect(page.getByText("Add Your First Camera")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Secure your account" }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("skipping sends nothing", async ({ frigateApp, page }) => {
|
||||
await installFirstRun(frigateApp, page);
|
||||
const sent = await captureUserCalls(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoAccountStep(page);
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
await expect(page.getByText("Add Your First Camera")).toBeVisible();
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
test("an account change alone needs no restart", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page);
|
||||
await captureUserCalls(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoAccountStep(page);
|
||||
|
||||
await page.getByRole("button", { name: "Change password" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog
|
||||
.getByPlaceholder("Enter new password", { exact: true })
|
||||
.fill("a-long-enough-password");
|
||||
await dialog
|
||||
.getByPlaceholder("Re-enter new password")
|
||||
.fill("a-long-enough-password");
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("Password set")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(page.getByText("Add Your First Camera")).toBeVisible();
|
||||
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
|
||||
// saves nothing, then Skip on recording
|
||||
await expect(page.getByText("Object Detection")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
await expect(page.getByText("You're done!")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Go to Live View" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Frigate needs to restart to apply your settings"),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Setup wizard hardware tests -- HIGH tier.
|
||||
*
|
||||
* Covers the detector step's probed radio list and the models: payload it
|
||||
* writes, the model-required deferral for onnx hardware, the hwaccel step's
|
||||
* Auto option writing the preset derived from the chosen hardware, and the
|
||||
* completion screen only restarting when a saved step requires it.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { gotoDetectorStep, installFirstRun } from "../../helpers/setup-wizard";
|
||||
|
||||
const NVIDIA_HARDWARE = [
|
||||
{
|
||||
key: "onnx:nvidia",
|
||||
detector: "onnx",
|
||||
name: "NVIDIA GeForce RTX 3060",
|
||||
units: [{ device: "onnx:0", label: "NVIDIA GeForce RTX 3060" }],
|
||||
count: 1,
|
||||
unlimited: true,
|
||||
},
|
||||
{
|
||||
key: "cpu",
|
||||
detector: "cpu",
|
||||
name: "CPU",
|
||||
units: [{ device: "cpu", label: "CPU" }],
|
||||
count: 1,
|
||||
unlimited: true,
|
||||
},
|
||||
];
|
||||
|
||||
type SavedConfig = {
|
||||
config_data?: {
|
||||
models?: { devices: string[]; path?: string }[];
|
||||
detect?: { enabled?: boolean };
|
||||
ffmpeg?: { hwaccel_args?: string | string[] };
|
||||
};
|
||||
};
|
||||
|
||||
async function captureSaves(page: Page): Promise<SavedConfig[]> {
|
||||
const saves: SavedConfig[] = [];
|
||||
await page.route("**/api/config/set**", (route) => {
|
||||
saves.push(route.request().postDataJSON() as SavedConfig);
|
||||
return route.fulfill({ json: { success: true, require_restart: true } });
|
||||
});
|
||||
return saves;
|
||||
}
|
||||
|
||||
async function captureRestarts(page: Page): Promise<string[]> {
|
||||
const calls: string[] = [];
|
||||
await page.route("**/api/restart", (route) => {
|
||||
calls.push(route.request().url());
|
||||
return route.fulfill({ json: { success: true, message: "Restarting" } });
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
test.describe("setup wizard hardware @high @mobile", () => {
|
||||
test("lists probed hardware and writes a models config", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page, {
|
||||
hwaccel: {
|
||||
recommended: "vaapi",
|
||||
available: [
|
||||
{ key: "vaapi", presets: { any: "preset-vaapi" } },
|
||||
{
|
||||
key: "intel-qsv",
|
||||
presets: {
|
||||
h264: "preset-intel-qsv-h264",
|
||||
h265: "preset-intel-qsv-h265",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const saves = await captureSaves(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoDetectorStep(page);
|
||||
|
||||
// the default hardware mock reports two Corals, an Intel GPU, and the CPU
|
||||
await expect(
|
||||
page.getByRole("radio", { name: /Coral EdgeTPU \(PCIe\) \(2\)/ }),
|
||||
).toBeChecked();
|
||||
await expect(page.getByText("Recommended")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||
|
||||
const detectorSave = saves.find((save) => save.config_data?.models);
|
||||
expect(detectorSave?.config_data?.models).toEqual([
|
||||
{ devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
expect(detectorSave?.config_data?.detect).toEqual({ enabled: true });
|
||||
|
||||
// VAAPI decodes any codec, so one global value covers every camera
|
||||
await expect(page.getByText("Will use VAAPI (Intel/AMD)")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
|
||||
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
|
||||
hwaccel_args: "preset-vaapi",
|
||||
});
|
||||
|
||||
// the saved steps only take effect after a restart
|
||||
const restarts = await captureRestarts(page);
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("You're done!")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText("Frigate needs to restart to apply your settings"),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Apply & Restart" }).click();
|
||||
|
||||
await expect(page.getByText("Starting Frigate...")).toBeVisible();
|
||||
expect(restarts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("defers model setup for onnx hardware without Frigate+", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page, {
|
||||
hardware: NVIDIA_HARDWARE,
|
||||
});
|
||||
const saves = await captureSaves(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoDetectorStep(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }),
|
||||
).toBeChecked();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Continue without detection" })
|
||||
.click();
|
||||
|
||||
// advances without touching the config
|
||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||
expect(saves.filter((save) => save.config_data?.models)).toHaveLength(0);
|
||||
|
||||
// nothing derived and nothing saved, so finishing needs no restart
|
||||
const restarts = await captureRestarts(page);
|
||||
await expect(page.getByText("No supported video card found")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("You're done!")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText("Frigate needs to restart to apply your settings"),
|
||||
).toBeHidden();
|
||||
await page.getByRole("button", { name: "Go to Live View" }).click();
|
||||
|
||||
// hands off without restarting, and the wizard does not come back
|
||||
await expect(page.getByText("Welcome to Frigate")).toBeHidden();
|
||||
expect(restarts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("offers only the presets the hardware supports", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page, {
|
||||
hardware: NVIDIA_HARDWARE,
|
||||
hwaccel: {
|
||||
recommended: "nvidia",
|
||||
available: [{ key: "nvidia", presets: { any: "preset-nvidia" } }],
|
||||
},
|
||||
});
|
||||
await captureSaves(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoDetectorStep(page);
|
||||
await page
|
||||
.getByRole("button", { name: "Continue without detection" })
|
||||
.click();
|
||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||
|
||||
// an NVIDIA box has no business being offered Rockchip or Pi decoding
|
||||
await expect(
|
||||
page.getByRole("radio", { name: "CUDA (NVIDIA)" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("radio", { name: /Raspberry Pi/ }),
|
||||
).toBeHidden();
|
||||
await expect(page.getByRole("radio", { name: /Rockchip/ })).toBeHidden();
|
||||
|
||||
// Auto and None are always available
|
||||
await expect(page.getByRole("radio", { name: "Auto" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("radio", { name: "None (software decoding)" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("a codec specific family falls back to h264 with no cameras", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page, {
|
||||
hwaccel: {
|
||||
recommended: "jetson",
|
||||
available: [
|
||||
{
|
||||
key: "jetson",
|
||||
presets: {
|
||||
h264: "preset-jetson-h264",
|
||||
h265: "preset-jetson-h265",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const saves = await captureSaves(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoDetectorStep(page);
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole("radio", { name: "NVIDIA Jetson" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// no camera was added, so there is no codec to match
|
||||
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
|
||||
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
|
||||
hwaccel_args: "preset-jetson-h264",
|
||||
});
|
||||
});
|
||||
|
||||
test("None writes an explicit empty hwaccel list", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
await installFirstRun(frigateApp, page);
|
||||
const saves = await captureSaves(page);
|
||||
|
||||
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||
await gotoDetectorStep(page);
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||
|
||||
await page.getByRole("radio", { name: "None (software decoding)" }).click();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
|
||||
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({ hwaccel_args: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"setupWizard": {
|
||||
"steps": {
|
||||
"welcome": "Welcome",
|
||||
"account": "Account",
|
||||
"camera": "Add Camera",
|
||||
"hwaccel": "Acceleration",
|
||||
"detector": "Detection",
|
||||
"recording": "Recording",
|
||||
"complete": "Done"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to Frigate",
|
||||
"description": "Let's get your security cameras set up. We'll walk through camera connection, hardware settings, and recording.",
|
||||
"getStarted": "Get Started",
|
||||
"skipSetup": "Skip setup and configure manually"
|
||||
},
|
||||
"account": {
|
||||
"title": "Secure your account",
|
||||
"descriptionSignedIn": "You're signed in as {{username}} using the temporary password from the Frigate logs. Set one you'll remember.",
|
||||
"descriptionAnonymous": "You're accessing Frigate on a port that doesn't require a login. Set a password for the admin account so you can sign in on the secured port.",
|
||||
"passwordSet": "Password set",
|
||||
"changePassword": "Change password",
|
||||
"addUser": "Add user",
|
||||
"usersFailed": "Could not load the user list. You can still set the admin password.",
|
||||
"userFailed": "Failed to add the user. Please try again."
|
||||
},
|
||||
"camera": {
|
||||
"title": "Add Your First Camera",
|
||||
"description": "Connect a camera to start monitoring. You can also add more cameras later in Settings.",
|
||||
"addCamera": "Add Camera",
|
||||
"addAnother": "Add Another Camera",
|
||||
"cameraAdded": "Camera added successfully",
|
||||
"retry": "Try Again"
|
||||
},
|
||||
"hwaccel": {
|
||||
"title": "Hardware Acceleration",
|
||||
"description": "Speed up video decoding with your GPU.",
|
||||
"detecting": "Checking your hardware...",
|
||||
"auto": "Auto",
|
||||
"autoResolved": "Will use {{family}} for your cameras.",
|
||||
"autoNone": "No supported video card found. Frigate will decide at startup.",
|
||||
"recommendFailed": "Hardware detection is unavailable. Frigate will decide at startup.",
|
||||
"families": {
|
||||
"nvidia": "CUDA (NVIDIA)",
|
||||
"vaapi": "VAAPI (Intel/AMD)",
|
||||
"intel-qsv": "QuickSync (Intel)",
|
||||
"rkmpp": "RKMPP (Rockchip)",
|
||||
"jetson": "NVIDIA Jetson",
|
||||
"rpi": "V4L2 (Raspberry Pi)",
|
||||
"none": "None (software decoding)"
|
||||
}
|
||||
},
|
||||
"detector": {
|
||||
"title": "Object Detection",
|
||||
"description": "Choose the hardware Frigate uses to detect people and objects.",
|
||||
"detecting": "Checking for detection hardware...",
|
||||
"probeFailed": "Hardware detection is unavailable. You can configure detection later in Settings.",
|
||||
"recommended": "Recommended",
|
||||
"modelRequired": "{{name}} needs a detection model before it can run. Pick a Frigate+ model, or finish setup and add one under Settings > Detection models.",
|
||||
"plusModelPlaceholder": "Select a Frigate+ model",
|
||||
"continueWithout": "Continue without detection"
|
||||
},
|
||||
"recording": {
|
||||
"title": "Recordings",
|
||||
"description": "Save video from your cameras so you can watch it later.",
|
||||
"enableRecording": "Enable recordings",
|
||||
"retentionDays": "Keep recordings for (days)",
|
||||
"storageEstimate": "With {{free}} GB free, {{cameras}} camera(s) recording around the clock fills the disk in roughly {{days}} days.",
|
||||
"noCameras": "You haven't added cameras yet. Recording will apply when you add cameras in Settings.",
|
||||
"modeLabel": "What to record",
|
||||
"modes": {
|
||||
"events": {
|
||||
"label": "Only when something is detected",
|
||||
"description": "Saves video around people, cars, and other objects Frigate detects. Uses far less disk space."
|
||||
},
|
||||
"continuous": {
|
||||
"label": "All the time",
|
||||
"description": "Saves video around the clock, so you can go back to any moment. Uses much more disk space."
|
||||
}
|
||||
},
|
||||
"retentionHint": {
|
||||
"events": "Video of anything Frigate detects is kept this long, then deleted automatically.",
|
||||
"continuous": "All video is kept this long, then deleted automatically."
|
||||
}
|
||||
},
|
||||
"complete": {
|
||||
"title": "You're done!",
|
||||
"description": "Your Frigate system is configured. Here's what we set up:",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"configureInSettings": "Configure in Settings",
|
||||
"camera": "Camera",
|
||||
"hwaccel": "Hardware Acceleration",
|
||||
"detector": "Object Detection",
|
||||
"recording": "Recording",
|
||||
"goToLiveView": "Go to Live View",
|
||||
"applyAndRestart": "Apply & Restart",
|
||||
"restartNotice": "Frigate needs to restart to apply your settings. This takes about 30 seconds.",
|
||||
"nextSteps": "Next steps: Set up motion masks, zones, and notifications in Settings.",
|
||||
"restarting": "Starting Frigate...",
|
||||
"restartingDescription": "This takes about 30 seconds."
|
||||
},
|
||||
"actions": {
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"skip": "Skip",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"errors": {
|
||||
"saveFailed": "Failed to save configuration. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -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, useContext, useEffect, useState } from "react";
|
||||
import { Redirect } from "./components/navigation/Redirect";
|
||||
import { cn } from "./lib/utils";
|
||||
import { isPWA } from "./utils/isPWA";
|
||||
@@ -15,6 +15,9 @@ 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"));
|
||||
const Events = lazy(() => import("@/pages/Events"));
|
||||
@@ -30,6 +33,7 @@ const Chat = lazy(() => import("@/pages/Chat"));
|
||||
const Logs = lazy(() => import("@/pages/Logs"));
|
||||
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||
const Replay = lazy(() => import("@/pages/Replay"));
|
||||
const SetupWizard = lazy(() => import("@/pages/SetupWizard"));
|
||||
|
||||
function App() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@@ -52,6 +56,24 @@ 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>();
|
||||
const { auth } = useContext(AuthContext);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
useEffect(() => {
|
||||
// every step writes through admin only endpoints, and the role isn't
|
||||
// known until the profile resolves
|
||||
if (config && !auth.isLoading && showWizard === undefined) {
|
||||
setShowWizard(
|
||||
isAdmin &&
|
||||
Object.keys(config.cameras ?? {}).length === 0 &&
|
||||
!isSetupDismissed(),
|
||||
);
|
||||
}
|
||||
}, [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
|
||||
const mainRouteRoles = config?.auth?.roles
|
||||
@@ -68,6 +90,21 @@ function DefaultAppView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup wizard for first-time users
|
||||
if (showWizard) {
|
||||
return (
|
||||
<div className="size-full overflow-hidden">
|
||||
<Suspense
|
||||
fallback={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
>
|
||||
<SetupWizard />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-hidden">
|
||||
{isDesktop && <Sidebar />}
|
||||
|
||||
@@ -74,11 +74,14 @@ const STEPS = [
|
||||
type CameraWizardDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
// lets callers reuse what was probed here instead of probing again
|
||||
onCameraAdded?: (camera: { name: string; detectCodec?: string }) => void;
|
||||
};
|
||||
|
||||
export default function CameraWizardDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCameraAdded,
|
||||
}: CameraWizardDialogProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { mutate: updateConfig } = useSWR("config");
|
||||
@@ -271,6 +274,13 @@ export default function CameraWizardDialog({
|
||||
.put("config/set", requestBody)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
onCameraAdded?.({
|
||||
name: finalCameraName,
|
||||
detectCodec: wizardData.streams?.find((stream) =>
|
||||
stream.roles.includes("detect"),
|
||||
)?.testResult?.videoCodec,
|
||||
});
|
||||
|
||||
// Configure go2rtc streams for all streams
|
||||
if (wizardData.streams && wizardData.streams.length > 0) {
|
||||
const go2rtcStreams: Record<string, string[]> = {};
|
||||
@@ -393,7 +403,7 @@ export default function CameraWizardDialog({
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, t, onClose],
|
||||
[updateConfig, t, onClose, onCameraAdded],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import CreateUserDialog from "@/components/overlay/CreateUserDialog";
|
||||
import SetPasswordDialog from "@/components/overlay/SetPasswordDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import axios from "axios";
|
||||
import { useCallback, useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
type User = {
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
type SetupAccountProps = {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export default function SetupAccount({
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: SetupAccountProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
const { auth } = useContext(AuthContext);
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isLoading,
|
||||
error: usersError,
|
||||
mutate: mutateUsers,
|
||||
} = useSWR<User[]>("users", { revalidateOnFocus: false });
|
||||
|
||||
// the internal port has no signed in user, so the built-in admin is the
|
||||
// account being secured
|
||||
const adminUsername = auth.isAuthenticated
|
||||
? (auth.user?.username ?? "admin")
|
||||
: "admin";
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [passwordSet, setPasswordSet] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const handleSavePassword = useCallback(
|
||||
(password: string) => {
|
||||
setPasswordSaving(true);
|
||||
axios
|
||||
.put(`users/${adminUsername}/password`, { password })
|
||||
.then(() => {
|
||||
setShowPassword(false);
|
||||
setPasswordError(null);
|
||||
setPasswordSet(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPasswordError(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
t("setupWizard.errors.saveFailed"),
|
||||
);
|
||||
})
|
||||
.finally(() => setPasswordSaving(false));
|
||||
},
|
||||
[adminUsername, t],
|
||||
);
|
||||
|
||||
const handleCreateUser = useCallback(
|
||||
(username: string, password: string, role: string) =>
|
||||
axios
|
||||
.post("users", { username, password, role })
|
||||
.then(() => {
|
||||
setShowCreate(false);
|
||||
mutateUsers();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
t("setupWizard.account.userFailed"),
|
||||
);
|
||||
}),
|
||||
[mutateUsers, t],
|
||||
);
|
||||
|
||||
const otherUsers = (users ?? []).filter(
|
||||
(user) => user.username !== adminUsername,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.account.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{auth.isAuthenticated
|
||||
? t("setupWizard.account.descriptionSignedIn", {
|
||||
username: adminUsername,
|
||||
})
|
||||
: t("setupWizard.account.descriptionAnonymous")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{adminUsername}</span>
|
||||
{passwordSet && (
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FaCircleCheck className="size-3 text-success" />
|
||||
{t("setupWizard.account.passwordSet")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
{t("setupWizard.account.changePassword")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{otherUsers.map((user) => (
|
||||
<div
|
||||
key={user.username}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{user.username}</span>
|
||||
<span className="text-xs text-muted-foreground">{user.role}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading && <ActivityIndicator />}
|
||||
|
||||
{usersError && (
|
||||
<p className="rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||
{t("setupWizard.account.usersFailed")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<Button
|
||||
variant="select"
|
||||
className="w-full"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
{t("setupWizard.account.addUser")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
<Button type="button" onClick={onSkip}>
|
||||
{t("setupWizard.actions.skip")}
|
||||
</Button>
|
||||
<Button type="button" variant="select" onClick={onNext}>
|
||||
{t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* no username prop: passing one puts the dialog in current-password
|
||||
mode, which the admin is exempt from and an anonymous internal port
|
||||
user has no way to satisfy */}
|
||||
<SetPasswordDialog
|
||||
show={showPassword}
|
||||
initialError={passwordError}
|
||||
isLoading={passwordSaving}
|
||||
onSave={handleSavePassword}
|
||||
onCancel={() => {
|
||||
setShowPassword(false);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CreateUserDialog
|
||||
show={showCreate}
|
||||
onCreate={handleCreateUser}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
|
||||
type SetupCameraProps = {
|
||||
onNext: (
|
||||
cameraNames?: string[],
|
||||
detectCodecs?: Record<string, string>,
|
||||
) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [addedCameras, setAddedCameras] = useState<string[]>([]);
|
||||
const [detectCodecs, setDetectCodecs] = useState<Record<string, string>>({});
|
||||
const handleClose = useCallback(() => {
|
||||
setShowWizard(false);
|
||||
}, []);
|
||||
|
||||
// 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 }));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
onNext(addedCameras, detectCodecs);
|
||||
}, [onNext, addedCameras, detectCodecs]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onNext();
|
||||
}, [onNext]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.camera.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.camera.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addedCameras.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{addedCameras.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{name}</span>
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<Button
|
||||
variant="select"
|
||||
className="w-full"
|
||||
onClick={() => setShowWizard(true)}
|
||||
>
|
||||
{addedCameras.length > 0
|
||||
? t("setupWizard.camera.addAnother")
|
||||
: t("setupWizard.camera.addCamera")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
{addedCameras.length > 0 ? (
|
||||
<Button type="button" variant="select" onClick={handleNext}>
|
||||
{t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" onClick={handleSkip}>
|
||||
{t("setupWizard.actions.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CameraWizardDialog
|
||||
open={showWizard}
|
||||
onClose={handleClose}
|
||||
onCameraAdded={handleCameraAdded}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import Logo from "@/components/Logo";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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;
|
||||
label: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
type SetupCompleteProps = {
|
||||
cameraNames: string[];
|
||||
configuredSteps: {
|
||||
camera: boolean;
|
||||
hwaccel: boolean;
|
||||
detector: boolean;
|
||||
recording: boolean;
|
||||
};
|
||||
restartRequired: boolean;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export default function SetupComplete({
|
||||
cameraNames,
|
||||
configuredSteps,
|
||||
restartRequired,
|
||||
onBack,
|
||||
}: SetupCompleteProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const cameraItems: ConfiguredItem[] =
|
||||
configuredSteps.camera && cameraNames.length > 0
|
||||
? cameraNames.map((name) => ({
|
||||
key: `camera-${name}`,
|
||||
label: t("setupWizard.complete.camera"),
|
||||
value: name,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
key: "camera",
|
||||
label: t("setupWizard.complete.camera"),
|
||||
value: null,
|
||||
},
|
||||
];
|
||||
|
||||
const items: ConfiguredItem[] = [
|
||||
...cameraItems,
|
||||
{
|
||||
key: "hwaccel",
|
||||
label: t("setupWizard.complete.hwaccel"),
|
||||
value: configuredSteps.hwaccel
|
||||
? t("setupWizard.complete.configured")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
key: "detector",
|
||||
label: t("setupWizard.complete.detector"),
|
||||
value: configuredSteps.detector
|
||||
? t("setupWizard.complete.configured")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
key: "recording",
|
||||
label: t("setupWizard.complete.recording"),
|
||||
value: configuredSteps.recording
|
||||
? t("setupWizard.complete.configured")
|
||||
: null,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFinish = useCallback(async () => {
|
||||
setFinishing(true);
|
||||
dismissSetup();
|
||||
|
||||
try {
|
||||
// camera adds were applied live, so nothing is waiting on a restart
|
||||
if (!restartRequired) {
|
||||
window.location.href = window.baseUrl || "/";
|
||||
return;
|
||||
}
|
||||
|
||||
setRestarting(true);
|
||||
|
||||
await axios.post("restart");
|
||||
|
||||
let retries = 0;
|
||||
const maxRetries = 60; // 2 minutes max
|
||||
pollRef.current = setInterval(async () => {
|
||||
retries++;
|
||||
if (retries > maxRetries) {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
window.location.href = window.baseUrl || "/";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await axios.get("version", { timeout: 2000 });
|
||||
if (resp.status === 200) {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
window.location.href = window.baseUrl || "/";
|
||||
}
|
||||
} catch {
|
||||
// not back yet
|
||||
}
|
||||
}, 2000);
|
||||
} catch {
|
||||
setRestarting(false);
|
||||
setFinishing(false);
|
||||
toast.error(t("setupWizard.errors.saveFailed"));
|
||||
}
|
||||
}, [restartRequired, t]);
|
||||
|
||||
if (restarting) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-12">
|
||||
<Logo className="h-12 w-12" />
|
||||
<ActivityIndicator />
|
||||
<div className="text-center">
|
||||
<p className="font-semibold">
|
||||
{t("setupWizard.complete.restarting")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.complete.restartingDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.complete.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.complete.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.value && <FaCircleCheck className="size-4 text-success" />}
|
||||
<span
|
||||
className={`text-sm ${item.value ? "" : "text-muted-foreground"}`}
|
||||
>
|
||||
{item.value ?? t("setupWizard.complete.notConfigured")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("setupWizard.complete.nextSteps")}
|
||||
</p>
|
||||
|
||||
{restartRequired && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
{t("setupWizard.complete.restartNotice")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
onClick={handleFinish}
|
||||
disabled={finishing}
|
||||
>
|
||||
{restartRequired
|
||||
? t("setupWizard.complete.applyAndRestart")
|
||||
: t("setupWizard.complete.goToLiveView")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import type { FrigatePlusModel } from "@/components/config-form/theme/fields/ModelSourcePicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { DetectionHardware } from "@/types/hardware";
|
||||
import { recommendedDetectorCount } from "@/utils/detectionHardware";
|
||||
import axios from "axios";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
// these ship no default model, so configuring one without a model leaves the
|
||||
// detector unable to start
|
||||
const MODEL_REQUIRED_DETECTORS = ["onnx", "tensorrt"];
|
||||
|
||||
const CPU_FALLBACK: DetectionHardware[] = [
|
||||
{
|
||||
key: "cpu",
|
||||
detector: "cpu",
|
||||
name: "CPU",
|
||||
units: [{ device: "cpu", label: "CPU" }],
|
||||
count: 1,
|
||||
unlimited: true,
|
||||
},
|
||||
];
|
||||
|
||||
type SetupDetectorProps = {
|
||||
cameraCount: number;
|
||||
onNext: (hardwareKey: string) => void;
|
||||
onBack: () => void;
|
||||
onSkip: (hardwareKey?: string) => void;
|
||||
};
|
||||
|
||||
export default function SetupDetector({
|
||||
cameraCount,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: SetupDetectorProps) {
|
||||
const { t } = useTranslation(["views/setup", "common"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
const {
|
||||
data: hardware,
|
||||
isLoading,
|
||||
error: probeError,
|
||||
} = useSWR<DetectionHardware[]>("hardware/probe", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const plusEnabled = Boolean(config?.plus?.enabled);
|
||||
|
||||
// the cpu is always probed, so an empty list means the probe failed
|
||||
const options = useMemo(
|
||||
() => (hardware && hardware.length > 0 ? hardware : CPU_FALLBACK),
|
||||
[hardware],
|
||||
);
|
||||
|
||||
// the prober orders accelerators ahead of the cpu
|
||||
const recommendedKey = options[0].key;
|
||||
const [selectedKey, setSelectedKey] = useState<string>();
|
||||
const selected =
|
||||
options.find((entry) => entry.key === (selectedKey ?? recommendedKey)) ??
|
||||
options[0];
|
||||
|
||||
const needsModel = MODEL_REQUIRED_DETECTORS.includes(selected.detector);
|
||||
|
||||
const { data: plusModels } = useSWR<FrigatePlusModel[]>(
|
||||
plusEnabled && needsModel ? "/plus/models" : null,
|
||||
{
|
||||
fetcher: async (url) => {
|
||||
const res = await axios.get(url, { withCredentials: true });
|
||||
return res.data;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [plusModelId, setPlusModelId] = useState("");
|
||||
|
||||
const compatiblePlusModels = useMemo(
|
||||
() =>
|
||||
(plusModels ?? []).filter((model) =>
|
||||
model.supportedDetectors.includes(selected.detector),
|
||||
),
|
||||
[plusModels, selected.detector],
|
||||
);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const buildDevices = useCallback(
|
||||
(entry: DetectionHardware): string[] => {
|
||||
const first = entry.units[0]?.device;
|
||||
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!entry.unlimited) {
|
||||
return [first];
|
||||
}
|
||||
|
||||
// repeating a device runs an extra inference process on it
|
||||
const count = recommendedDetectorCount(Math.max(cameraCount, 1));
|
||||
return Array.from({ length: count }, () => first);
|
||||
},
|
||||
[cameraCount],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (needsModel && !plusModelId) {
|
||||
onSkip(selected.key);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const model: Record<string, unknown> = {
|
||||
devices: buildDevices(selected),
|
||||
};
|
||||
|
||||
if (needsModel) {
|
||||
model.path = `plus://${plusModelId}`;
|
||||
}
|
||||
|
||||
await axios.put("config/set", {
|
||||
config_data: {
|
||||
models: [model],
|
||||
detect: { enabled: true },
|
||||
},
|
||||
requires_restart: 1,
|
||||
});
|
||||
onNext(selected.key);
|
||||
} catch {
|
||||
toast.error(t("setupWizard.errors.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [needsModel, plusModelId, selected, buildDevices, onNext, onSkip, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<ActivityIndicator />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("setupWizard.detector.detecting")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.detector.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.detector.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{probeError && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
{t("setupWizard.detector.probeFailed")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<RadioGroup
|
||||
value={selected.key}
|
||||
onValueChange={(value) => {
|
||||
setSelectedKey(value);
|
||||
setPlusModelId("");
|
||||
}}
|
||||
>
|
||||
{options.map((entry) => (
|
||||
<div key={entry.key} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={entry.key}
|
||||
id={`detector-${entry.key}`}
|
||||
className={
|
||||
selected.key === entry.key
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`detector-${entry.key}`}
|
||||
className="cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{entry.name}
|
||||
{entry.count > 1 ? ` (${entry.count})` : ""}
|
||||
{entry.key === recommendedKey && entry.key !== "cpu" && (
|
||||
<span className="ml-2 text-xs text-selected">
|
||||
{t("setupWizard.detector.recommended")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{needsModel && (
|
||||
<div className="flex flex-col gap-3 rounded-md bg-muted p-3 text-sm">
|
||||
<p>
|
||||
{t("setupWizard.detector.modelRequired", { name: selected.name })}
|
||||
</p>
|
||||
{plusEnabled ? (
|
||||
<Select value={plusModelId} onValueChange={setPlusModelId}>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue
|
||||
placeholder={t("setupWizard.detector.plusModelPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{compatiblePlusModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{`${model.name} (${model.width}x${model.height})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<a
|
||||
href={getLocaleDocUrl("configuration/object_detectors")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-primary"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
<Button type="button" onClick={() => onSkip(selected.key)}>
|
||||
{t("setupWizard.actions.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? t("setupWizard.actions.saving")
|
||||
: needsModel && !plusModelId
|
||||
? t("setupWizard.detector.continueWithout")
|
||||
: t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import type { HwaccelFamily, HwaccelRecommendation } from "@/types/hardware";
|
||||
import axios from "axios";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
const AUTO = "auto";
|
||||
const NONE = "none";
|
||||
|
||||
const ANY_CODEC = "any";
|
||||
|
||||
// ffprobe names h265 streams hevc
|
||||
const CODEC_ALIASES: Record<string, string> = { hevc: "h265" };
|
||||
|
||||
function normalizeCodec(codec: string): string {
|
||||
const lower = codec.toLowerCase();
|
||||
return CODEC_ALIASES[lower] ?? lower;
|
||||
}
|
||||
|
||||
type SetupHwAccelProps = {
|
||||
detectorHardwareKey?: string;
|
||||
// camera name -> detect stream codec, the only stream hwaccel applies to
|
||||
detectCodecs: Record<string, string>;
|
||||
// saved tells the wizard whether finishing needs a restart
|
||||
onNext: (saved: boolean) => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export default function SetupHwAccel({
|
||||
detectorHardwareKey,
|
||||
detectCodecs,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: SetupHwAccelProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
|
||||
const cameraCodecs = useMemo(
|
||||
() =>
|
||||
Object.entries(detectCodecs).map(([camera, codec]) => ({
|
||||
camera,
|
||||
codec: normalizeCodec(codec),
|
||||
})),
|
||||
[detectCodecs],
|
||||
);
|
||||
|
||||
const query = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (detectorHardwareKey) {
|
||||
params.set("detector", detectorHardwareKey);
|
||||
}
|
||||
|
||||
const codecs = [...new Set(cameraCodecs.map((entry) => entry.codec))];
|
||||
|
||||
if (codecs.length > 0) {
|
||||
params.set("codecs", codecs.join(","));
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}, [detectorHardwareKey, cameraCodecs]);
|
||||
|
||||
const {
|
||||
data: recommendation,
|
||||
isLoading,
|
||||
error: recommendError,
|
||||
} = useSWR<HwaccelRecommendation>(
|
||||
query ? `hardware/hwaccel?${query}` : "hardware/hwaccel",
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const [selected, setSelected] = useState<string>(AUTO);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const families = useMemo(
|
||||
() => recommendation?.available ?? [],
|
||||
[recommendation],
|
||||
);
|
||||
const derived = recommendation?.recommended ?? "";
|
||||
|
||||
/** The config a family should be saved as, or null when it writes nothing. */
|
||||
const configFor = useCallback(
|
||||
(family: HwaccelFamily | undefined): Record<string, unknown> | null => {
|
||||
if (!family) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shared = family.presets[ANY_CODEC];
|
||||
|
||||
if (shared) {
|
||||
return { ffmpeg: { hwaccel_args: shared } };
|
||||
}
|
||||
|
||||
const perCamera = cameraCodecs
|
||||
.map((entry) => ({ ...entry, preset: family.presets[entry.codec] }))
|
||||
.filter((entry) => entry.preset);
|
||||
|
||||
if (perCamera.length === 0) {
|
||||
const fallback = Object.values(family.presets)[0];
|
||||
return fallback ? { ffmpeg: { hwaccel_args: fallback } } : null;
|
||||
}
|
||||
|
||||
const presets = new Set(perCamera.map((entry) => entry.preset));
|
||||
|
||||
if (presets.size === 1 && perCamera.length === cameraCodecs.length) {
|
||||
return { ffmpeg: { hwaccel_args: [...presets][0] } };
|
||||
}
|
||||
|
||||
// the global stays on auto so cameras added later resolve at startup
|
||||
// instead of inheriting one camera's codec
|
||||
return {
|
||||
cameras: Object.fromEntries(
|
||||
perCamera.map((entry) => [
|
||||
entry.camera,
|
||||
{ ffmpeg: { hwaccel_args: entry.preset } },
|
||||
]),
|
||||
),
|
||||
};
|
||||
},
|
||||
[cameraCodecs],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const key = selected === AUTO ? derived : selected;
|
||||
|
||||
const configData =
|
||||
selected === NONE
|
||||
? // an empty string would make config/set delete the key, reviving
|
||||
// the "auto" default
|
||||
{ ffmpeg: { hwaccel_args: [] } }
|
||||
: configFor(families.find((family) => family.key === key));
|
||||
|
||||
// nothing to write leaves the config default of "auto" in place
|
||||
if (!configData) {
|
||||
onNext(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
config_data: configData,
|
||||
requires_restart: 1,
|
||||
});
|
||||
onNext(true);
|
||||
} catch {
|
||||
toast.error(t("setupWizard.errors.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selected, derived, families, configFor, onNext, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<ActivityIndicator />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("setupWizard.hwaccel.detecting")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const radioClass = (value: string) =>
|
||||
selected === value
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.hwaccel.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.hwaccel.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RadioGroup value={selected} onValueChange={setSelected}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={AUTO}
|
||||
id="hwaccel-auto"
|
||||
className={radioClass(AUTO)}
|
||||
/>
|
||||
<label htmlFor="hwaccel-auto" className="cursor-pointer text-sm">
|
||||
{t("setupWizard.hwaccel.auto")}
|
||||
</label>
|
||||
</div>
|
||||
<p className="ml-6 text-xs text-muted-foreground">
|
||||
{derived
|
||||
? t("setupWizard.hwaccel.autoResolved", {
|
||||
family: t(`setupWizard.hwaccel.families.${derived}`),
|
||||
})
|
||||
: recommendError
|
||||
? t("setupWizard.hwaccel.recommendFailed")
|
||||
: t("setupWizard.hwaccel.autoNone")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{families.map((family) => (
|
||||
<div key={family.key} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={family.key}
|
||||
id={`hwaccel-${family.key}`}
|
||||
className={radioClass(family.key)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`hwaccel-${family.key}`}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{t(`setupWizard.hwaccel.families.${family.key}`)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={NONE}
|
||||
id="hwaccel-none"
|
||||
className={radioClass(NONE)}
|
||||
/>
|
||||
<label htmlFor="hwaccel-none" className="cursor-pointer text-sm">
|
||||
{t("setupWizard.hwaccel.families.none")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
<Button type="button" onClick={onSkip}>
|
||||
{t("setupWizard.actions.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? t("setupWizard.actions.saving")
|
||||
: t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import useSWR from "swr";
|
||||
|
||||
const EVENTS = "events";
|
||||
const CONTINUOUS = "continuous";
|
||||
|
||||
const MODES = [EVENTS, CONTINUOUS] as const;
|
||||
|
||||
type SetupRecordingProps = {
|
||||
cameraNames: string[];
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export default function SetupRecording({
|
||||
cameraNames,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: SetupRecordingProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [mode, setMode] = useState<string>(EVENTS);
|
||||
const [retentionDays, setRetentionDays] = useState(10);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { data: stats } = useSWR("stats", { revalidateOnFocus: false });
|
||||
|
||||
const storageInfo = stats?.service?.storage?.["/tmp/frigate/recordings"];
|
||||
const freeGb = storageInfo ? Math.round(storageInfo.free / 1024) : null;
|
||||
const cameraCount = cameraNames.length;
|
||||
// Rough estimate: ~2 Mbps per camera continuous recording
|
||||
const estimatedDays =
|
||||
freeGb && cameraCount > 0
|
||||
? Math.round((freeGb * 1024) / ((2 * 0.125 * 86400) / 1024) / cameraCount)
|
||||
: null;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const record: Record<string, unknown> = { enabled };
|
||||
|
||||
if (enabled) {
|
||||
record.alerts = { retain: { days: retentionDays } };
|
||||
record.detections = { retain: { days: retentionDays } };
|
||||
// written even when off, so switching modes back turns it off again
|
||||
record.continuous = { days: mode === CONTINUOUS ? retentionDays : 0 };
|
||||
}
|
||||
|
||||
await axios.put("config/set", {
|
||||
config_data: { record },
|
||||
requires_restart: 1,
|
||||
});
|
||||
onNext();
|
||||
} catch {
|
||||
toast.error(t("setupWizard.errors.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [enabled, mode, retentionDays, onNext, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.recording.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.recording.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{cameraCount === 0 && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
{t("setupWizard.recording.noCameras")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border p-4">
|
||||
<Label htmlFor="recording-toggle" className="font-medium">
|
||||
{t("setupWizard.recording.enableRecording")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="recording-toggle"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t("setupWizard.recording.modeLabel")}</Label>
|
||||
<RadioGroup value={mode} onValueChange={setMode}>
|
||||
{MODES.map((option) => (
|
||||
<div key={option} className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={option}
|
||||
id={`recording-mode-${option}`}
|
||||
className={
|
||||
mode === option
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`recording-mode-${option}`}
|
||||
className="cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{t(`setupWizard.recording.modes.${option}.label`)}
|
||||
</label>
|
||||
</div>
|
||||
<p className="ml-6 text-xs text-muted-foreground">
|
||||
{t(`setupWizard.recording.modes.${option}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="retention-days">
|
||||
{t("setupWizard.recording.retentionDays")}
|
||||
</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={retentionDays}
|
||||
// drop the spinner arrows; typing and arrow keys still work
|
||||
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
onChange={(e) =>
|
||||
setRetentionDays(Math.max(1, parseInt(e.target.value) || 1))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`setupWizard.recording.retentionHint.${mode}`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === CONTINUOUS &&
|
||||
freeGb !== null &&
|
||||
estimatedDays !== null &&
|
||||
cameraCount > 0 && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
{t("setupWizard.recording.storageEstimate", {
|
||||
free: freeGb,
|
||||
days: estimatedDays,
|
||||
cameras: cameraCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
<Button type="button" onClick={onSkip}>
|
||||
{t("setupWizard.actions.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? t("setupWizard.actions.saving")
|
||||
: t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Logo from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SetupWelcomeProps = {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export default function SetupWelcome({ onNext, onSkip }: SetupWelcomeProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-4">
|
||||
<Logo className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{t("setupWizard.welcome.title")}
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t("setupWizard.welcome.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 pt-4">
|
||||
<Button variant="select" className="w-full" onClick={onNext}>
|
||||
{t("setupWizard.welcome.getStarted")}
|
||||
</Button>
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-primary"
|
||||
onClick={onSkip}
|
||||
>
|
||||
{t("setupWizard.welcome.skipSetup")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,3 +11,14 @@ export type DetectionHardware = {
|
||||
count: number;
|
||||
unlimited: boolean;
|
||||
};
|
||||
|
||||
export type HwaccelFamily = {
|
||||
key: string;
|
||||
// keyed by codec, or a single "any" preset when it decodes every codec
|
||||
presets: Record<string, string>;
|
||||
};
|
||||
|
||||
export type HwaccelRecommendation = {
|
||||
recommended: string;
|
||||
available: HwaccelFamily[];
|
||||
};
|
||||
|
||||
@@ -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