mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
adding backup/restore ability
This commit is contained in:
85
ui/src/services/backupRestoreClient.js
Normal file
85
ui/src/services/backupRestoreClient.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lightweight client for Backup & Restore interactions with the backend.
|
||||
*
|
||||
* Usage (in React components):
|
||||
* ```js
|
||||
* import { downloadBackup, precheckRestore, restore } from '../../services/backupRestoreClient';
|
||||
* await downloadBackup();
|
||||
* const info = await precheckRestore(file);
|
||||
* await restore(file, false);
|
||||
* ```
|
||||
*/
|
||||
|
||||
function extractFileNameFromDisposition(disposition) {
|
||||
const dispo = disposition || '';
|
||||
const match = dispo.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/);
|
||||
return decodeURIComponent(match?.[1] || match?.[2] || 'FredyBackup.zip');
|
||||
}
|
||||
|
||||
export class BackupRestoreClient {
|
||||
/**
|
||||
* Trigger a backup download and save it using the filename provided by the server.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async downloadBackup() {
|
||||
const resp = await fetch('/api/admin/backup', { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Failed to create backup');
|
||||
const blob = await resp.blob();
|
||||
const fileName = extractFileNameFromDisposition(resp.headers.get('Content-Disposition'));
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a backup zip for analysis without restoring.
|
||||
* @param {Blob|ArrayBuffer|Buffer} file - Backup zip content.
|
||||
* @returns {Promise<{compatible:boolean,severity:string,message:string,backupMigration:number|null,requiredMigration:number,fredyVersion?:string|null}>>}
|
||||
*/
|
||||
static async precheckRestore(file) {
|
||||
const resp = await fetch('/api/admin/backup/restore?dryRun=true', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
body: file,
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a database restore from a backup zip.
|
||||
* @param {Blob|ArrayBuffer|Buffer} file - Backup zip content.
|
||||
* @param {boolean} force - When true, proceed even if reported incompatible.
|
||||
* @returns {Promise<{restored:true,warning:string|null,details:any}>}
|
||||
*/
|
||||
static async restore(file, force) {
|
||||
const resp = await fetch(`/api/admin/backup/restore?force=${force ? 'true' : 'false'}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
body: file,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
const err = new Error(data?.message || 'Restore failed');
|
||||
err.payload = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience named exports
|
||||
export const downloadBackup = (...args) => BackupRestoreClient.downloadBackup(...args);
|
||||
export const precheckRestore = (...args) => BackupRestoreClient.precheckRestore(...args);
|
||||
export const restore = (...args) => BackupRestoreClient.restore(...args);
|
||||
@@ -7,11 +7,16 @@ import React from 'react';
|
||||
|
||||
import { useActions, useSelector } from '../../services/state/store';
|
||||
|
||||
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
|
||||
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui';
|
||||
import { InputNumber } from '@douyinfe/semi-ui';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import { Banner, Toast } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
downloadBackup as downloadBackupZip,
|
||||
precheckRestore as clientPrecheckRestore,
|
||||
restore as clientRestore,
|
||||
} from '../../services/backupRestoreClient';
|
||||
import {
|
||||
IconSave,
|
||||
IconCalendar,
|
||||
@@ -52,6 +57,11 @@ const GeneralSettings = function GeneralSettings() {
|
||||
const [demoMode, setDemoMode] = React.useState(null);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
|
||||
const [sqlitePath, setSqlitePath] = React.useState(null);
|
||||
const fileInputRef = React.useRef(null);
|
||||
const [restoreModalVisible, setRestoreModalVisible] = React.useState(false);
|
||||
const [precheckInfo, setPrecheckInfo] = React.useState(null);
|
||||
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
||||
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function init() {
|
||||
@@ -78,7 +88,7 @@ const GeneralSettings = function GeneralSettings() {
|
||||
|
||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||
|
||||
const onStore = async () => {
|
||||
const handleStore = async () => {
|
||||
if (nullOrEmpty(interval)) {
|
||||
Toast.error('Interval may not be empty.');
|
||||
return;
|
||||
@@ -125,6 +135,60 @@ const GeneralSettings = function GeneralSettings() {
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleDownloadBackup = React.useCallback(async () => {
|
||||
try {
|
||||
await downloadBackupZip();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Unexpected error while downloading backup.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const precheckRestore = React.useCallback(async (file) => {
|
||||
try {
|
||||
const data = await clientPrecheckRestore(file);
|
||||
setPrecheckInfo(data);
|
||||
setRestoreModalVisible(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Failed to analyze backup.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const performRestore = React.useCallback(
|
||||
async (force) => {
|
||||
try {
|
||||
setRestoreBusy(true);
|
||||
await clientRestore(selectedRestoreFile, force);
|
||||
Toast.success('Restore completed. Please restart the Fredy backend now!');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error(e?.message || 'Unexpected error while restoring backup.');
|
||||
} finally {
|
||||
setRestoreBusy(false);
|
||||
}
|
||||
},
|
||||
[selectedRestoreFile],
|
||||
);
|
||||
|
||||
const handleSelectRestoreFile = React.useCallback(
|
||||
async (ev) => {
|
||||
const file = ev?.target?.files?.[0];
|
||||
if (!file) return;
|
||||
setSelectedRestoreFile(file);
|
||||
await precheckRestore(file);
|
||||
// reset the input to allow same file re-select
|
||||
ev.target.value = '';
|
||||
},
|
||||
[precheckRestore],
|
||||
);
|
||||
|
||||
const handleOpenFilePicker = React.useCallback(() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!loading && (
|
||||
@@ -146,6 +210,28 @@ const GeneralSettings = function GeneralSettings() {
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Backup & Restore"
|
||||
helpText="Download a zipped backup of your database or restore it from a backup zip."
|
||||
Icon={IconSave}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
|
||||
Download backup
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleSelectRestoreFile}
|
||||
/>
|
||||
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
|
||||
Restore from zip
|
||||
</Button>
|
||||
</div>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -271,12 +357,55 @@ const GeneralSettings = function GeneralSettings() {
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave />}>
|
||||
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{restoreModalVisible && (
|
||||
<Modal
|
||||
title="Restore database"
|
||||
visible={restoreModalVisible}
|
||||
onCancel={() => setRestoreModalVisible(false)}
|
||||
onOk={() => performRestore(!precheckInfo?.compatible)}
|
||||
okText={precheckInfo?.compatible ? 'Restore now' : 'Restore anyway'}
|
||||
okType={precheckInfo?.compatible ? 'primary' : 'danger'}
|
||||
confirmLoading={restoreBusy}
|
||||
>
|
||||
{precheckInfo?.severity === 'danger' && (
|
||||
<Banner
|
||||
type="danger"
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Problem detected</div>}
|
||||
description={<div>{precheckInfo?.message}</div>}
|
||||
/>
|
||||
)}
|
||||
{precheckInfo?.severity === 'warning' && (
|
||||
<Banner
|
||||
type="warning"
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Automatic migrations will be applied</div>}
|
||||
description={<div>{precheckInfo?.message}</div>}
|
||||
/>
|
||||
)}
|
||||
{precheckInfo?.severity === 'info' && (
|
||||
<Banner
|
||||
type="success"
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Backup is compatible</div>}
|
||||
description={<div>{precheckInfo?.message}</div>}
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
|
||||
Backup migration: {precheckInfo?.backupMigration ?? 'unknown'} | Required migration:{' '}
|
||||
{precheckInfo?.requiredMigration ?? 'unknown'}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user