Add import/export for camera group layouts and per-camera streaming settings (#24025)

* 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
This commit is contained in:
Josh Hawkins 2026-08-18 12:45:06 -05:00
parent da135da0fb
commit fec73c887e
6 changed files with 1351 additions and 5 deletions

View File

@ -40,7 +40,7 @@ Deleting a group also clears any custom layout you saved for it.
## Rearranging a camera group layout
On desktop and tablet, each camera group has its own freely-arrangeable grid. Enter **Edit Layout** mode from the layout button in the lower-right corner: camera tiles gain a drag handle and corner resize handles. Drag a tile to reposition it and drag a corner to resize it (the aspect ratio is preserved). Exit edit mode to save. The layout is stored in your browser per device, so each device can have its own arrangement.
On desktop and tablet, each camera group has its own freely-arrangeable grid. Enter **Edit Layout** mode from the layout button in the lower-right corner: camera tiles gain a drag handle and corner resize handles. Drag a tile to reposition it and drag a corner to resize it (the aspect ratio is preserved). Exit edit mode to save. The layout is stored in your browser per device, so each device can have its own arrangement, and layouts can be exported to a file and imported on another device.
The default **All Cameras** dashboard is not manually arrangeable. It automatically sizes tiles based on each camera's aspect ratio (wide cameras span two columns, tall cameras span two rows).
@ -68,7 +68,7 @@ For non-default groups, the context menu also exposes **Streaming Settings** for
- the **streaming method**: **No Streaming**, **Smart Streaming** (recommended), or **Continuous Streaming** (higher bandwidth), and
- **compatibility mode**, for devices that have trouble rendering the default player.
These settings are saved per group and per device in your browser, not in your config file.
These settings are saved per group and per device in your browser, not in your config file, and can be exported to a file and imported on another device.
## The single-camera view

View File

@ -0,0 +1,444 @@
/**
* 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();
});
});

View File

@ -179,6 +179,31 @@
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
"clearAll": "Clear All Streaming Settings"
},
"backupRestore": {
"title": "Backup & Restore",
"transfer": {
"label": "Device Settings File",
"desc": "Camera group layouts, streaming settings, and UI preferences are stored in your browser. Export them to a file to back them up or to move them to another device.",
"export": "Export Settings",
"import": "Import Settings"
},
"importDialog": {
"title": "Import Settings",
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
"layouts_one": "Camera group layouts ({{count}} group)",
"layouts_other": "Camera group layouts ({{count}} groups)",
"streaming_one": "Streaming settings ({{count}} camera)",
"streaming_other": "Streaming settings ({{count}} cameras)",
"preferences_one": "UI preferences ({{count}} setting)",
"preferences_other": "UI preferences ({{count}} settings)",
"unknownGroups_one": "Camera group {{groups}} is not on this server. Its settings will be saved but unused.",
"unknownGroups_other": "Camera groups {{groups}} are not on this server. Their settings will be saved but unused.",
"unknownCameras_one": "Camera {{cameras}} is not on this server. Its streaming settings will be ignored.",
"unknownCameras_other": "Cameras {{cameras}} are not on this server. Their streaming settings will be ignored.",
"confirm": "Import"
}
},
"recordingsViewer": {
"title": "Recordings Viewer",
"defaultPlaybackRate": {
@ -198,11 +223,20 @@
"toast": {
"success": {
"clearStoredLayout": "Cleared stored layout for {{cameraName}}",
"clearStreamingSettings": "Cleared streaming settings for all camera groups."
"clearStreamingSettings": "Cleared streaming settings for all camera groups.",
"exportUiSettings": "Exported settings",
"importUiSettings": "Imported settings"
},
"error": {
"clearStoredLayoutFailed": "Failed to clear stored layout: {{errorMessage}}",
"clearStreamingSettingsFailed": "Failed to clear streaming settings: {{errorMessage}}"
"clearStreamingSettingsFailed": "Failed to clear streaming settings: {{errorMessage}}",
"exportUiSettingsFailed": "Failed to export settings",
"importNothingToApply": "Failed to import settings: file contains no settings",
"importInvalidJson": "Failed to import settings: file is not valid JSON",
"importWrongType": "Failed to import settings: file is not a Frigate settings export",
"importUnsupportedVersion": "Failed to import settings: file requires a newer version of Frigate",
"importInvalidSchema": "Failed to import settings: file is malformed",
"importUiSettingsFailed": "Failed to import settings"
}
}
},

View File

@ -0,0 +1,212 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { LuTriangleAlert } from "react-icons/lu";
import FilterSwitch from "@/components/filter/FilterSwitch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
ImportSummary,
TransferSection,
UiSettingsFile,
} from "@/utils/uiSettingsTransfer";
type ImportUiSettingsDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
file: UiSettingsFile;
summary: ImportSummary;
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
};
export default function ImportUiSettingsDialog({
open,
onOpenChange,
fileName,
file,
summary,
onConfirm,
}: ImportUiSettingsDialogProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [isImporting, setIsImporting] = useState(false);
const available = useMemo(
() => ({
layouts: summary.layoutGroupCount > 0,
streaming: summary.streamingCameraCount > 0,
preferences: summary.preferenceCount > 0,
}),
[summary],
);
const [sections, setSections] =
useState<Record<TransferSection, boolean>>(available);
// a new file can be chosen while this component stays mounted, so the
// toggles reset to what the current file actually offers each time it
// opens
useEffect(() => {
if (open) {
setSections(available);
setIsImporting(false);
}
}, [open, available]);
const canImport = useMemo(
() =>
!isImporting &&
(Object.keys(sections) as TransferSection[]).some(
(section) => sections[section] && available[section],
),
[isImporting, sections, available],
);
// warn only about what the enabled sections will actually write
const visibleUnknownGroups = useMemo(() => {
const groups = new Set<string>();
if (sections.layouts) {
summary.unknownLayoutGroups.forEach((group) => groups.add(group));
}
if (sections.streaming) {
summary.unknownStreamingGroups.forEach((group) => groups.add(group));
}
return Array.from(groups).sort();
}, [sections, summary]);
const visibleUnknownCameras = useMemo(
() => (sections.streaming ? summary.unknownCameras : []),
[sections.streaming, summary.unknownCameras],
);
const handleConfirm = useCallback(async () => {
setIsImporting(true);
await onConfirm(sections);
setIsImporting(false);
}, [onConfirm, sections]);
const exportedDate = useMemo(() => {
const parsed = new Date(file.exported_at);
return isNaN(parsed.getTime()) ? file.exported_at : parsed.toLocaleString();
}, [file.exported_at]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="scrollbar-container max-h-[80dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("general.backupRestore.importDialog.title")}
</DialogTitle>
<DialogDescription>
{t("general.backupRestore.importDialog.desc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-0.5">
<p className="break-all text-base text-primary-variant">{fileName}</p>
<p className="text-sm text-muted-foreground">
{t("general.backupRestore.importDialog.exportedFrom", {
date: exportedDate,
version: file.frigate_version,
})}
</p>
</div>
<div className="space-y-3">
<FilterSwitch
label={t("general.backupRestore.importDialog.layouts", {
count: summary.layoutGroupCount,
})}
isChecked={sections.layouts}
disabled={!available.layouts || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, layouts: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.streaming", {
count: summary.streamingCameraCount,
})}
isChecked={sections.streaming}
disabled={!available.streaming || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, streaming: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.preferences", {
count: summary.preferenceCount,
})}
isChecked={sections.preferences}
disabled={!available.preferences || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, preferences: checked }))
}
/>
</div>
{(visibleUnknownGroups.length > 0 ||
visibleUnknownCameras.length > 0) && (
<Alert variant="warning">
<LuTriangleAlert className="size-5" />
<AlertDescription className="space-y-2">
{visibleUnknownGroups.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownGroups", {
count: visibleUnknownGroups.length,
groups: visibleUnknownGroups.join(", "),
})}
</p>
)}
{visibleUnknownCameras.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownCameras", {
count: visibleUnknownCameras.length,
cameras: visibleUnknownCameras.join(", "),
})}
</p>
)}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={() => onOpenChange(false)}
disabled={isImporting}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("general.backupRestore.importDialog.confirm")}
onClick={handleConfirm}
disabled={!canImport}
>
{isImporting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("general.backupRestore.importDialog.confirm")}</span>
</div>
) : (
t("general.backupRestore.importDialog.confirm")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,459 @@
import { get as getData, set as setData } from "idb-keyval";
import { z } from "zod";
import { getUserNamespacedKey } from "@/hooks/use-user-persistence";
export const UI_SETTINGS_FILE_TYPE = "frigate-ui-settings";
export const UI_SETTINGS_FILE_VERSION = 1;
export type TransferSection = "layouts" | "streaming" | "preferences";
const cameraStreamingSettingsSchema = z.object({
streamName: z.string(),
streamType: z.enum(["no-streaming", "smart", "continuous"]),
compatibilityMode: z.boolean(),
playAudio: z.boolean(),
volume: z.number(),
});
const allGroupsStreamingSettingsSchema = z.record(
z.string(),
z.record(z.string(), cameraStreamingSettingsSchema),
);
type TransferKey = {
key: string;
section: Exclude<TransferSection, "layouts">;
namespaced: boolean;
// preference values are validated individually on import: an out-of-range
// value would otherwise be written and crash the view that reads it
schema: z.ZodType;
};
// Single source of truth for which browser-stored settings move between
// devices. Layout keys are derived per camera group at export time and so
// are not listed here. `namespaced` mirrors which persistence hook wrote
// the key: useUserPersistence namespaces by username, usePersistence does
// not.
export const TRANSFER_KEYS: TransferKey[] = [
{
key: "streaming-settings",
section: "streaming",
namespaced: true,
schema: allGroupsStreamingSettingsSchema,
},
{
key: "autoLiveView",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "displayCameraNames",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "alertVideos",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "liveFallbackTimeout",
section: "preferences",
namespaced: true,
schema: z.number().int().min(1).max(60),
},
{
key: "playbackRate",
section: "preferences",
namespaced: true,
schema: z.number().positive().max(64),
},
{
key: "weekStartsOn",
section: "preferences",
namespaced: true,
schema: z.union([z.literal(0), z.literal(1)]),
},
{
key: "showReviewed",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "exploreGridColumns",
section: "preferences",
namespaced: true,
schema: z.number().int().min(1).max(12),
},
{
key: "exploreDefaultView",
section: "preferences",
namespaced: true,
schema: z.enum(["summary", "grid"]),
},
{
key: "detailStreamActiveExpanded",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "hlsPlayerMuted",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "recordingQuality",
section: "preferences",
namespaced: false,
schema: z.enum(["auto", "main", "sub"]),
},
{
key: "chat-show-stats",
section: "preferences",
namespaced: false,
schema: z.enum(["while_generating", "always"]),
},
{
key: "chat-auto-scroll",
section: "preferences",
namespaced: false,
schema: z.boolean(),
},
{
key: "chat-thinking-enabled",
section: "preferences",
namespaced: false,
schema: z.boolean(),
},
];
export function layoutKeyForGroup(group: string): string {
return `${group}-draggable-layout`;
}
function storageKey(
entry: { key: string; namespaced: boolean },
username: string | undefined,
): string {
return entry.namespaced
? getUserNamespacedKey(entry.key, username)
: entry.key;
}
// useUserPersistence migrates legacy un-namespaced keys lazily, on first
// mount of the hook that owns each key, so a value from a view the user
// has not opened since upgrading still lives under the bare key.
async function readTransferable(
key: string,
namespaced: boolean,
username: string | undefined,
) {
if (!namespaced) {
return getData(key);
}
const namespacedKey = getUserNamespacedKey(key, username);
const value = await getData(namespacedKey);
if (value !== undefined || namespacedKey === key) {
return value;
}
return getData(key);
}
// Layout items carry optional react-grid-layout fields (minW, static,
// moved, and others) that vary by version, so unknown keys pass through
// rather than failing validation.
const layoutItemSchema = z
.object({
i: z.string(),
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
})
.passthrough();
export const uiSettingsFileSchema = z.object({
type: z.literal(UI_SETTINGS_FILE_TYPE),
version: z.number().int().positive(),
exported_at: z.string(),
frigate_version: z.string(),
sections: z.object({
layouts: z.record(z.string(), z.array(layoutItemSchema)),
streaming: allGroupsStreamingSettingsSchema,
preferences: z.record(z.string(), z.unknown()),
}),
});
export type UiSettingsFile = z.infer<typeof uiSettingsFileSchema>;
export async function buildExportPayload(
groupNames: string[],
frigateVersion: string,
username: string | undefined,
): Promise<UiSettingsFile> {
const layouts: UiSettingsFile["sections"]["layouts"] = {};
let streaming: UiSettingsFile["sections"]["streaming"] = {};
const preferences: UiSettingsFile["sections"]["preferences"] = {};
await Promise.all(
groupNames.map(async (group) => {
const value = await readTransferable(
layoutKeyForGroup(group),
true,
username,
);
if (value !== undefined) {
layouts[group] = value;
}
}),
);
await Promise.all(
TRANSFER_KEYS.map(async (entry) => {
const value = await readTransferable(
entry.key,
entry.namespaced,
username,
);
if (value === undefined) {
return;
}
if (entry.section === "streaming") {
// this key holds the entire group -> camera map, so its value is
// the section payload rather than one entry within it
streaming = value;
return;
}
preferences[entry.key] = value;
}),
);
return {
type: UI_SETTINGS_FILE_TYPE,
version: UI_SETTINGS_FILE_VERSION,
exported_at: new Date().toISOString(),
frigate_version: frigateVersion,
sections: { layouts, streaming, preferences },
};
}
export function exportFileName(now: Date): string {
// local date rather than toISOString: a user west of UTC exporting in
// the evening would otherwise get tomorrow's date in the filename
const month = `${now.getMonth() + 1}`.padStart(2, "0");
const day = `${now.getDate()}`.padStart(2, "0");
return `frigate-ui-settings-${now.getFullYear()}-${month}-${day}.json`;
}
export function downloadJson(payload: unknown, fileName: string): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const element = document.createElement("a");
element.href = url;
element.download = fileName;
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
URL.revokeObjectURL(url);
}
export type ParseError =
| "invalid_json"
| "wrong_type"
| "unsupported_version"
| "invalid_schema";
export type ParseResult =
| { ok: true; file: UiSettingsFile }
| { ok: false; error: ParseError };
export function parseUiSettingsFile(text: string): ParseResult {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return { ok: false, error: "invalid_json" };
}
if (
typeof raw !== "object" ||
raw === null ||
(raw as { type?: unknown }).type !== UI_SETTINGS_FILE_TYPE
) {
return { ok: false, error: "wrong_type" };
}
const version = (raw as { version?: unknown }).version;
if (typeof version === "number" && version > UI_SETTINGS_FILE_VERSION) {
return { ok: false, error: "unsupported_version" };
}
const parsed = uiSettingsFileSchema.safeParse(raw);
if (!parsed.success) {
return { ok: false, error: "invalid_schema" };
}
return { ok: true, file: parsed.data };
}
export type ImportSummary = {
layoutGroupCount: number;
streamingCameraCount: number;
preferenceCount: number;
// unknown groups are split by the section that named them so the dialog
// can warn only about sections the user is actually importing
unknownLayoutGroups: string[];
unknownStreamingGroups: string[];
unknownCameras: string[];
};
// the only place preference entries are accepted: the import counts and the
// writes must agree, or the dialog reports settings it will not apply
function validPreferenceEntries(
preferences: UiSettingsFile["sections"]["preferences"],
): { entry: TransferKey; value: unknown }[] {
const accepted: { entry: TransferKey; value: unknown }[] = [];
TRANSFER_KEYS.forEach((entry) => {
if (entry.section !== "preferences") {
return;
}
// hasOwnProperty rather than `in`, which walks the prototype chain
if (!Object.prototype.hasOwnProperty.call(preferences, entry.key)) {
return;
}
const parsed = entry.schema.safeParse(preferences[entry.key]);
if (parsed.success) {
accepted.push({ entry, value: parsed.data });
}
});
return accepted;
}
export function summarizeImport(
file: UiSettingsFile,
knownGroups: string[],
knownCameras: string[],
): ImportSummary {
const layoutGroups = Object.keys(file.sections.layouts);
const streamingGroups = Object.keys(file.sections.streaming);
const streamingCameras = new Set<string>();
Object.values(file.sections.streaming).forEach((group) =>
Object.keys(group).forEach((camera) => streamingCameras.add(camera)),
);
const knownGroupSet = new Set(knownGroups);
const knownCameraSet = new Set(knownCameras);
return {
layoutGroupCount: layoutGroups.length,
streamingCameraCount: streamingCameras.size,
preferenceCount: validPreferenceEntries(file.sections.preferences).length,
unknownLayoutGroups: layoutGroups
.filter((group) => !knownGroupSet.has(group))
.sort(),
unknownStreamingGroups: streamingGroups
.filter((group) => !knownGroupSet.has(group))
.sort(),
unknownCameras: Array.from(streamingCameras)
.filter((camera) => !knownCameraSet.has(camera))
.sort(),
};
}
export function hasImportableContent(summary: ImportSummary): boolean {
return (
summary.layoutGroupCount > 0 ||
summary.streamingCameraCount > 0 ||
summary.preferenceCount > 0
);
}
export async function applyImportPayload(
file: UiSettingsFile,
sections: Record<TransferSection, boolean>,
username: string | undefined,
): Promise<void> {
const writes: Promise<void>[] = [];
if (sections.layouts) {
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
writes.push(
setData(
getUserNamespacedKey(layoutKeyForGroup(group), username),
layout,
),
);
});
}
const streamingEntry = TRANSFER_KEYS.find(
(entry) => entry.section === "streaming",
);
if (
sections.streaming &&
streamingEntry &&
Object.keys(file.sections.streaming).length > 0
) {
writes.push(
(async () => {
// one key holds every group and every camera within it, so merge
// at camera level: a group or a camera configured only on this
// device must survive an import that does not mention it
const existing =
(await readTransferable(
streamingEntry.key,
streamingEntry.namespaced,
username,
)) ?? {};
const merged: UiSettingsFile["sections"]["streaming"] = {
...existing,
};
Object.entries(file.sections.streaming).forEach(([group, cameras]) => {
merged[group] = { ...(existing[group] ?? {}), ...cameras };
});
await setData(storageKey(streamingEntry, username), merged);
})(),
);
}
if (sections.preferences) {
validPreferenceEntries(file.sections.preferences).forEach(
({ entry, value }) => {
writes.push(setData(storageKey(entry, username), value));
},
);
}
await Promise.all(writes);
}

View File

@ -1,6 +1,14 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ReactNode, useCallback, useContext, useEffect } from "react";
import {
ChangeEvent,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import useSWR from "swr";
@ -26,8 +34,23 @@ import {
CONTROL_COLUMN_CLASS_NAME,
} from "@/components/card/SettingsGroupCard";
import Heading from "@/components/ui/heading";
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
import {
applyImportPayload,
buildExportPayload,
downloadJson,
exportFileName,
hasImportableContent,
ImportSummary,
ParseError,
parseUiSettingsFile,
summarizeImport,
TransferSection,
UiSettingsFile,
} from "@/utils/uiSettingsTransfer";
const WEEK_STARTS_ON = ["Sunday", "Monday"];
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
type SwitchSettingRowProps = {
id: string;
@ -168,10 +191,132 @@ export default function UiSettingsView() {
});
}, [config, t, username]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<{
name: string;
file: UiSettingsFile;
summary: ImportSummary;
} | null>(null);
const importErrorMessage = useCallback(
(error: ParseError) => {
// literal keys per branch: a template key would be invisible to
// npm run i18n:extract, which CI verifies
switch (error) {
case "invalid_json":
return t("general.toast.error.importInvalidJson");
case "wrong_type":
return t("general.toast.error.importWrongType");
case "unsupported_version":
return t("general.toast.error.importUnsupportedVersion");
case "invalid_schema":
return t("general.toast.error.importInvalidSchema");
}
},
[t],
);
const handleExport = useCallback(async () => {
if (!config || auth.isLoading) {
return;
}
let payload: UiSettingsFile;
try {
payload = await buildExportPayload(
Object.keys(config.camera_groups),
config.version,
username,
);
} catch {
toast.error(t("general.toast.error.exportUiSettingsFailed"), {
position: "top-center",
});
return;
}
downloadJson(payload, exportFileName(new Date()));
toast.success(t("general.toast.success.exportUiSettings"), {
position: "top-center",
});
}, [config, auth.isLoading, username, t]);
const handleFileSelected = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files?.[0];
// reset so choosing the same file again still fires a change event
event.target.value = "";
if (!selected || !config || auth.isLoading) {
return;
}
const result = parseUiSettingsFile(await selected.text());
if (!result.ok) {
toast.error(importErrorMessage(result.error), {
position: "top-center",
});
return;
}
const summary = summarizeImport(
result.file,
Object.keys(config.camera_groups),
Object.keys(config.cameras),
);
// a file exported from a browser with nothing stored is structurally
// valid, and would open a dialog with every switch disabled
if (!hasImportableContent(summary)) {
toast.error(t("general.toast.error.importNothingToApply"), {
position: "top-center",
});
return;
}
setPendingImport({ name: selected.name, file: result.file, summary });
},
[config, auth.isLoading, importErrorMessage, t],
);
const handleImportConfirm = useCallback(
async (sections: Record<TransferSection, boolean>) => {
if (!pendingImport || auth.isLoading) {
return;
}
try {
await applyImportPayload(pendingImport.file, sections, username);
} catch {
// writes are already in flight when one rejects, so reload anyway:
// staying mounted lets the persistence providers write their stale
// state back over whatever did land
sessionStorage.setItem(IMPORT_FAILED_FLAG, "1");
}
window.location.reload();
},
[pendingImport, auth.isLoading, username],
);
useEffect(() => {
document.title = t("documentTitle.general");
}, [t]);
useEffect(() => {
if (!sessionStorage.getItem(IMPORT_FAILED_FLAG)) {
return;
}
sessionStorage.removeItem(IMPORT_FAILED_FLAG);
toast.error(t("general.toast.error.importUiSettingsFailed"), {
position: "top-center",
});
}, [t]);
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
const [cameraNames, setCameraName] = useUserPersistence(
"displayCameraNames",
@ -300,6 +445,43 @@ export default function UiSettingsView() {
</div>
</SettingsGroupCard>
<SettingsGroupCard title={t("general.backupRestore.title")}>
<ValueSettingRow
id="ui-settings-transfer"
label={t("general.backupRestore.transfer.label")}
description={t("general.backupRestore.transfer.desc")}
control={
<div className="flex flex-col gap-2 md:flex-row">
<Button
id="ui-settings-export"
aria-label={t("general.backupRestore.transfer.export")}
className="w-full md:w-auto"
disabled={auth.isLoading || !config}
onClick={handleExport}
>
{t("general.backupRestore.transfer.export")}
</Button>
<Button
id="ui-settings-import"
aria-label={t("general.backupRestore.transfer.import")}
className="w-full md:w-auto"
disabled={auth.isLoading || !config}
onClick={() => fileInputRef.current?.click()}
>
{t("general.backupRestore.transfer.import")}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={handleFileSelected}
/>
</div>
}
/>
</SettingsGroupCard>
<SettingsGroupCard title={t("general.recordingsViewer.title")}>
<ValueSettingRow
id="default-playback-rate"
@ -376,6 +558,21 @@ export default function UiSettingsView() {
</SettingsGroupCard>
</div>
</div>
{pendingImport && (
<ImportUiSettingsDialog
open={pendingImport != null}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
fileName={pendingImport.name}
file={pendingImport.file}
summary={pendingImport.summary}
onConfirm={handleImportConfirm}
/>
)}
</div>
);
}