mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 11:38:59 +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
@@ -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