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-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 27a40a507b
commit 257a05a7e2
6 changed files with 1351 additions and 5 deletions
@@ -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>
);
}