mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 13:58:58 +03:00
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:
committed by
Nicolas Mowen
parent
27a40a507b
commit
257a05a7e2
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user