mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 02:28:59 +03:00
* add import/export for camera group layouts and streaming settings Camera group layouts and per-camera streaming settings are stored in the browser's IndexedDB, so they are tied to a single browser on a single device. Users with more than one device have to rebuild every group layout and re-pick every camera's stream settings by hand, and clearing browser data loses the work. Add a Backup & Restore card to Settings > UI Settings that exports these settings to a JSON file and imports that file on another device. Import shows a confirmation dialog with per-section counts, switches for layouts, streaming settings, and UI preferences, and warnings about camera groups or cameras in the file that are not on this server. Server-side storage is deliberately avoided. These are per-device presentation settings: a layout arranged for a desktop is wrong on a tablet, and continuous full-resolution streams that are free on a wired LAN are not on a phone. An explicit file moves settings only when the user chooses to move them. Implementation notes: - web/src/utils/uiSettingsTransfer.ts owns a registry of transferable IndexedDB keys. Each entry records whether the key is user-namespaced, matching which persistence hook wrote it, plus a zod schema for its value. - Only registry-known keys are ever written, and only when their value passes that schema. The file format deliberately lets unknown keys survive parsing, so this filter is what prevents a hand-edited file from writing arbitrary storage keys or out-of-range values. - Export falls back to the legacy un-namespaced key, because the username migration runs lazily on first mount of each owning hook. - Streaming settings merge per group rather than replacing the whole map, so groups configured only on the receiving device survive. - Import writes storage and then reloads, because useUserPersistence reads a key only on mount and StreamingSettingsProvider would otherwise write its stale in-memory state back over the import. - playbackBandwidthEstimate, frigate-search-history, and live-layout are excluded: the first two are measurements and user data rather than preferences, and live-layout's default is derived from the device. * merge imported streaming settings per camera instead of per group
445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
/**
|
|
* UI settings import/export tests -- MEDIUM tier.
|
|
*
|
|
* Covers exporting browser-stored layouts, streaming settings, and UI
|
|
* preferences to a file, and importing that file back.
|
|
*/
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { test, expect } from "../../fixtures/frigate-test";
|
|
import type { Page } from "@playwright/test";
|
|
|
|
// the mocked profile is admin, so useUserPersistence keys are namespaced
|
|
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
|
const STREAMING_KEY = "streaming-settings:admin";
|
|
|
|
const OUTDOOR_LAYOUT = [
|
|
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
|
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
|
];
|
|
|
|
const STREAMING_SETTINGS = {
|
|
outdoor: {
|
|
front_door: {
|
|
streamName: "front_door",
|
|
streamType: "smart",
|
|
compatibilityMode: false,
|
|
playAudio: false,
|
|
volume: 1,
|
|
},
|
|
},
|
|
};
|
|
|
|
async function writeIdb(page: Page, entries: Record<string, unknown>) {
|
|
await page.evaluate(async (data) => {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const request = indexedDB.open("keyval-store", 1);
|
|
request.onupgradeneeded = () =>
|
|
request.result.createObjectStore("keyval");
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
const tx = request.result.transaction("keyval", "readwrite");
|
|
const store = tx.objectStore("keyval");
|
|
Object.entries(data).forEach(([key, value]) => store.put(value, key));
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
};
|
|
});
|
|
}, entries);
|
|
}
|
|
|
|
async function readIdb(page: Page, key: string) {
|
|
return page.evaluate(async (target) => {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open("keyval-store", 1);
|
|
request.onupgradeneeded = () =>
|
|
request.result.createObjectStore("keyval");
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
const tx = request.result.transaction("keyval", "readonly");
|
|
const get = tx.objectStore("keyval").get(target);
|
|
get.onsuccess = () => resolve(get.result ?? null);
|
|
get.onerror = () => reject(get.error);
|
|
};
|
|
});
|
|
}, key);
|
|
}
|
|
|
|
function importPayload(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
type: "frigate-ui-settings",
|
|
version: 1,
|
|
exported_at: "2026-08-17T14:22:31.000Z",
|
|
frigate_version: "0.15.0-test",
|
|
sections: {
|
|
layouts: { outdoor: OUTDOOR_LAYOUT, patio: OUTDOOR_LAYOUT },
|
|
streaming: STREAMING_SETTINGS,
|
|
preferences: { playbackRate: 2, weekStartsOn: 1 },
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function chooseImportText(page: Page, contents: string) {
|
|
await page.locator('input[type="file"]').setInputFiles({
|
|
name: "frigate-ui-settings-2026-08-17.json",
|
|
mimeType: "application/json",
|
|
buffer: Buffer.from(contents),
|
|
});
|
|
}
|
|
|
|
async function chooseImportFile(page: Page, payload: unknown) {
|
|
await chooseImportText(page, JSON.stringify(payload));
|
|
}
|
|
|
|
async function confirmImport(page: Page) {
|
|
// Import triggers a real window.location.reload(), not a client-side
|
|
// route change, so #pageRoot is already present and waiting for it
|
|
// alone can race the navigation. Wait for the "load" event first so
|
|
// the evaluate() calls below run against the post-reload document.
|
|
await Promise.all([
|
|
page.waitForEvent("load"),
|
|
page.getByRole("button", { name: "Import" }).click(),
|
|
]);
|
|
await page.waitForSelector("#pageRoot");
|
|
}
|
|
|
|
async function clearIdb(page: Page) {
|
|
await page.evaluate(async () => {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const request = indexedDB.open("keyval-store", 1);
|
|
request.onupgradeneeded = () =>
|
|
request.result.createObjectStore("keyval");
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
const tx = request.result.transaction("keyval", "readwrite");
|
|
tx.objectStore("keyval").clear();
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
test.describe("UI settings import/export @medium", () => {
|
|
test("exports stored layouts, streaming settings, and preferences", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await writeIdb(frigateApp.page, {
|
|
[OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT,
|
|
[STREAMING_KEY]: STREAMING_SETTINGS,
|
|
"playbackRate:admin": 2,
|
|
});
|
|
|
|
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
await frigateApp.page
|
|
.getByRole("button", { name: "Export Settings" })
|
|
.click();
|
|
const download = await downloadPromise;
|
|
|
|
expect(download.suggestedFilename()).toMatch(
|
|
/^frigate-ui-settings-\d{4}-\d{2}-\d{2}\.json$/,
|
|
);
|
|
|
|
const path = await download.path();
|
|
const payload = JSON.parse(readFileSync(path!, "utf-8"));
|
|
|
|
expect(payload.type).toBe("frigate-ui-settings");
|
|
expect(payload.version).toBe(1);
|
|
expect(payload.frigate_version).toBe("0.15.0-test");
|
|
expect(payload.sections.layouts.outdoor).toEqual(OUTDOOR_LAYOUT);
|
|
expect(payload.sections.streaming).toEqual(STREAMING_SETTINGS);
|
|
expect(payload.sections.preferences.playbackRate).toBe(2);
|
|
});
|
|
|
|
test("omits settings that were never stored", async ({ frigateApp }) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
await frigateApp.page
|
|
.getByRole("button", { name: "Export Settings" })
|
|
.click();
|
|
const download = await downloadPromise;
|
|
|
|
const path = await download.path();
|
|
const payload = JSON.parse(readFileSync(path!, "utf-8"));
|
|
|
|
expect(payload.sections.layouts).toEqual({});
|
|
expect(payload.sections.streaming).toEqual({});
|
|
expect(payload.sections.preferences.playbackRate).toBeUndefined();
|
|
});
|
|
|
|
test("imports every section and reloads", async ({ frigateApp }) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await chooseImportFile(frigateApp.page, importPayload());
|
|
|
|
await expect(
|
|
frigateApp.page.getByText("Camera group layouts (2 groups)"),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByText("Streaming settings (1 camera)"),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByText("UI preferences (2 settings)"),
|
|
).toBeVisible();
|
|
await expect(frigateApp.page.getByText(/patio/)).toBeVisible();
|
|
|
|
await confirmImport(frigateApp.page);
|
|
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
OUTDOOR_LAYOUT,
|
|
);
|
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
|
STREAMING_SETTINGS,
|
|
);
|
|
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
|
|
});
|
|
|
|
test("hides the unknown-group warning when layouts are switched off", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
// patio is a layout-only group absent from this server, so the warning
|
|
// is meaningful only while the layouts section is being imported
|
|
await chooseImportFile(frigateApp.page, importPayload());
|
|
|
|
const warning = frigateApp.page.getByText(/patio/);
|
|
await expect(warning).toBeVisible();
|
|
|
|
await frigateApp.page.getByText("Camera group layouts (2 groups)").click();
|
|
await expect(warning).toBeHidden();
|
|
|
|
await frigateApp.page.getByText("Camera group layouts (2 groups)").click();
|
|
await expect(warning).toBeVisible();
|
|
});
|
|
|
|
test("leaves a section untouched when its switch is off", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await writeIdb(frigateApp.page, { [STREAMING_KEY]: {} });
|
|
await chooseImportFile(frigateApp.page, importPayload());
|
|
|
|
await frigateApp.page.getByText("Streaming settings (1 camera)").click();
|
|
await confirmImport(frigateApp.page);
|
|
|
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
OUTDOOR_LAYOUT,
|
|
);
|
|
});
|
|
|
|
test("rejects a file that is not a Frigate settings export", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await chooseImportFile(frigateApp.page, { type: "something-else" });
|
|
|
|
await expect(
|
|
frigateApp.page.getByText(
|
|
"Failed to import settings: file is not a Frigate settings export",
|
|
),
|
|
).toBeVisible();
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
});
|
|
|
|
test("ignores preference keys that are not in the registry", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await chooseImportFile(
|
|
frigateApp.page,
|
|
importPayload({
|
|
sections: {
|
|
layouts: {},
|
|
streaming: {},
|
|
preferences: { playbackRate: 2, notARealSetting: "malicious" },
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
frigateApp.page.getByText("UI preferences (1 setting)"),
|
|
).toBeVisible();
|
|
|
|
await confirmImport(frigateApp.page);
|
|
|
|
expect(await readIdb(frigateApp.page, "notARealSetting")).toBeNull();
|
|
expect(await readIdb(frigateApp.page, "notARealSetting:admin")).toBeNull();
|
|
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
|
|
});
|
|
|
|
test("round trips an export back through import", async ({ frigateApp }) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await writeIdb(frigateApp.page, {
|
|
[OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT,
|
|
[STREAMING_KEY]: STREAMING_SETTINGS,
|
|
"playbackRate:admin": 2,
|
|
"weekStartsOn:admin": 1,
|
|
});
|
|
|
|
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
await frigateApp.page
|
|
.getByRole("button", { name: "Export Settings" })
|
|
.click();
|
|
const download = await downloadPromise;
|
|
const filename = download.suggestedFilename();
|
|
const path = await download.path();
|
|
const bytes = readFileSync(path!);
|
|
|
|
await clearIdb(frigateApp.page);
|
|
// A plain page.reload() is not enough here: the settings view strips
|
|
// the `page` query param from the URL (history.replaceState) shortly
|
|
// after mount, and on mobile that param is what reopens the uiSettings
|
|
// drawer. Re-navigate with the param present so the reload is a real
|
|
// fresh load of the uiSettings pane rather than the settings list.
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
// A no-op import would still pass every assertion below, so prove the
|
|
// store is actually empty before feeding the export back in.
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
|
|
await frigateApp.page.locator('input[type="file"]').setInputFiles({
|
|
name: filename,
|
|
mimeType: "application/json",
|
|
buffer: Buffer.from(bytes),
|
|
});
|
|
|
|
await expect(
|
|
frigateApp.page.getByText("UI preferences (2 settings)"),
|
|
).toBeVisible();
|
|
|
|
await confirmImport(frigateApp.page);
|
|
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
OUTDOOR_LAYOUT,
|
|
);
|
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
|
STREAMING_SETTINGS,
|
|
);
|
|
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
|
|
expect(await readIdb(frigateApp.page, "weekStartsOn:admin")).toBe(1);
|
|
});
|
|
|
|
test("merges imported streaming settings with existing groups and cameras", async ({
|
|
frigateApp,
|
|
}) => {
|
|
// indoor is a group the file never mentions. outdoor.backyard is a
|
|
// camera inside a group the file DOES mention, so a shallow group-level
|
|
// merge would silently drop it. Both must survive.
|
|
const EXISTING_STREAMING = {
|
|
indoor: {
|
|
garage: {
|
|
streamName: "garage",
|
|
streamType: "continuous",
|
|
compatibilityMode: true,
|
|
playAudio: true,
|
|
volume: 0.5,
|
|
},
|
|
},
|
|
outdoor: {
|
|
backyard: {
|
|
streamName: "backyard",
|
|
streamType: "no-streaming",
|
|
compatibilityMode: false,
|
|
playAudio: false,
|
|
volume: 0,
|
|
},
|
|
},
|
|
};
|
|
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await writeIdb(frigateApp.page, { [STREAMING_KEY]: EXISTING_STREAMING });
|
|
await chooseImportFile(frigateApp.page, importPayload());
|
|
|
|
await expect(
|
|
frigateApp.page.getByText("Streaming settings (1 camera)"),
|
|
).toBeVisible();
|
|
|
|
await confirmImport(frigateApp.page);
|
|
|
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({
|
|
indoor: EXISTING_STREAMING.indoor,
|
|
outdoor: {
|
|
...EXISTING_STREAMING.outdoor,
|
|
...STREAMING_SETTINGS.outdoor,
|
|
},
|
|
});
|
|
});
|
|
test("rejects a file that is not valid JSON", async ({ frigateApp }) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await chooseImportText(frigateApp.page, "this is not json");
|
|
|
|
await expect(
|
|
frigateApp.page.getByText(
|
|
"Failed to import settings: file is not valid JSON",
|
|
),
|
|
).toBeVisible();
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
});
|
|
|
|
test("rejects a file from a newer version of Frigate", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await chooseImportFile(frigateApp.page, importPayload({ version: 99 }));
|
|
|
|
await expect(
|
|
frigateApp.page.getByText(
|
|
"Failed to import settings: file requires a newer version of Frigate",
|
|
),
|
|
).toBeVisible();
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
});
|
|
|
|
test("rejects a file whose sections are malformed", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
// correct type and version, so this only fails at schema validation
|
|
await chooseImportFile(
|
|
frigateApp.page,
|
|
importPayload({
|
|
sections: { layouts: "nope", streaming: {}, preferences: {} },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
frigateApp.page.getByText("Failed to import settings: file is malformed"),
|
|
).toBeVisible();
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
});
|
|
|
|
test("rejects a valid file that carries no settings", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/settings?page=uiSettings");
|
|
|
|
await chooseImportFile(
|
|
frigateApp.page,
|
|
importPayload({
|
|
sections: { layouts: {}, streaming: {}, preferences: {} },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
frigateApp.page.getByText(
|
|
"Failed to import settings: file contains no settings",
|
|
),
|
|
).toBeVisible();
|
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
});
|
|
});
|