feat: Add database import/export functionality (Issue #10)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Backend: - New /api/v1/backup/export endpoint - exports all data as single JSON - New /api/v1/backup/import endpoint - imports JSON with merge or replace - Handles vendors, filaments, spools, adjustments, settings - Remaps IDs to maintain relationships during import Frontend: - Added Backup & Restore section to Settings page - Download Backup button for export - Import with merge or replace options - Warning confirmation for replace mode This enables migrating between PostgreSQL and SQLite databases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { DownloadOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { Button, Checkbox, Form, Input, message } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { Button, Checkbox, Divider, Form, Input, message, Popconfirm, Space, Upload } from "antd";
|
||||
import type { UploadFile } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAPIURL } from "../../utils/url";
|
||||
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
|
||||
|
||||
export function GeneralSettings() {
|
||||
@@ -110,7 +113,141 @@ export function GeneralSettings() {
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<BackupRestore messageApi={messageApi} t={t} />
|
||||
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BackupRestoreProps {
|
||||
messageApi: ReturnType<typeof message.useMessage>[0];
|
||||
t: ReturnType<typeof useTranslate>;
|
||||
}
|
||||
|
||||
function BackupRestore({ messageApi, t }: BackupRestoreProps) {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await fetch(`${getAPIURL()}/backup/export`);
|
||||
if (!response.ok) throw new Error("Export failed");
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename = response.headers.get("content-disposition")?.match(/filename="(.+)"/)?.[1]
|
||||
|| `spoolman_backup_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
messageApi.success(t("settings.backup.export_success"));
|
||||
} catch (error) {
|
||||
messageApi.error(t("settings.backup.export_error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (replace: boolean) => {
|
||||
if (fileList.length === 0) {
|
||||
messageApi.warning(t("settings.backup.select_file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileList[0].originFileObj as Blob);
|
||||
|
||||
const response = await fetch(`${getAPIURL()}/backup/import?replace=${replace}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
messageApi.success(
|
||||
`${t("settings.backup.import_success")}: ${result.vendors_imported} vendors, ${result.filaments_imported} filaments, ${result.spools_imported} spools`
|
||||
);
|
||||
setFileList([]);
|
||||
// Reload the page to refresh all data
|
||||
window.location.reload();
|
||||
} else {
|
||||
messageApi.error(`${t("settings.backup.import_error")}: ${result.errors.join(", ")}`);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(t("settings.backup.import_error"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
|
||||
<h3>{t("settings.backup.title")}</h3>
|
||||
<p style={{ marginBottom: "1em", color: "#888" }}>
|
||||
{t("settings.backup.description")}
|
||||
</p>
|
||||
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<h4>{t("settings.backup.export_title")}</h4>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
{t("settings.backup.export_button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{t("settings.backup.import_title")}</h4>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<Upload
|
||||
accept=".json"
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
beforeUpload={() => false}
|
||||
onChange={({ fileList }) => setFileList(fileList)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>{t("settings.backup.select_file")}</Button>
|
||||
</Upload>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleImport(false)}
|
||||
loading={importing}
|
||||
disabled={fileList.length === 0}
|
||||
>
|
||||
{t("settings.backup.import_merge")}
|
||||
</Button>
|
||||
|
||||
<Popconfirm
|
||||
title={t("settings.backup.replace_warning_title")}
|
||||
description={t("settings.backup.replace_warning")}
|
||||
onConfirm={() => handleImport(true)}
|
||||
okText={t("buttons.yes")}
|
||||
cancelText={t("buttons.no")}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
loading={importing}
|
||||
disabled={fileList.length === 0}
|
||||
>
|
||||
{t("settings.backup.import_replace")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user