Simplify backup/restore with complete database + files ZIP approach

Replace the complex JSON-based backup system (~2000 lines) with a simple
  approach that copies the SQLite database and all data directories into a
  single ZIP file.

  Backend changes:
  - Add close_all_connections() and reinitialize_database() helpers to database.py
  - New GET /backup endpoint: creates complete ZIP with bambuddy.db and all
    data directories (archive, virtual_printer, plate_calibration, icons, projects)
  - New POST /restore endpoint: extracts ZIP, replaces database and directories,
    requires restart after restore
  - Move legacy endpoints to /backup-legacy and /restore-legacy for transition

  Frontend changes:
  - Simplify api.exportBackup() - no longer takes category parameters
  - Simplify api.importBackup() - no longer takes overwrite parameter
  - Remove BackupModal and RestoreModal components from GitHubBackupSettings
  - Add simple Download/Restore buttons with inline logic
  - Add blocking modal overlay during backup/restore operations
  - Add beforeunload handler to prevent accidental navigation
  - Show operation status messages during backup/restore

  Benefits:
  - ~100 lines vs ~2000 lines of backup/restore code
  - Complete by definition - SQLite database contains ALL data
  - No code changes needed when schema changes
  - No ID remapping required - IDs stay the same
  - Faster - file copy vs querying all tables
This commit is contained in:
maziggy
2026-02-01 08:35:49 +01:00
parent 001e328ce8
commit e1ff8f19e7
4 changed files with 329 additions and 73 deletions
+133 -3
View File
@@ -238,7 +238,65 @@ async def update_spoolman_settings(
@router.get("/backup")
async def export_backup(
async def create_backup(db: AsyncSession = Depends(get_db)):
"""Create a complete backup (database + all files) as a ZIP.
This is a simplified backup that includes the entire SQLite database
and all data directories. It is complete by definition and cannot miss data.
"""
import shutil
import tempfile
from sqlalchemy import text
from backend.app.core.database import engine
base_dir = app_settings.base_dir
db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# 1. Checkpoint WAL to ensure all data is in main db file
async with engine.begin() as conn:
await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
# 2. Copy database file
shutil.copy2(db_path, temp_path / "bambuddy.db")
# 3. Copy data directories (if they exist)
dirs_to_backup = [
("archive", base_dir / "archive"),
("virtual_printer", base_dir / "virtual_printer"),
("plate_calibration", app_settings.plate_calibration_dir),
("icons", base_dir / "icons"),
("projects", base_dir / "projects"),
]
for name, src_dir in dirs_to_backup:
if src_dir.exists() and any(src_dir.iterdir()):
shutil.copytree(src_dir, temp_path / name)
# 4. Create ZIP
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for file_path in temp_path.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(temp_path)
zf.write(file_path, arcname)
zip_buffer.seek(0)
filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/backup-legacy")
async def export_backup_legacy(
db: AsyncSession = Depends(get_db),
include_settings: bool = Query(True, description="Include app settings"),
include_notifications: bool = Query(True, description="Include notification providers"),
@@ -953,12 +1011,84 @@ async def export_backup(
@router.post("/restore")
async def import_backup(
async def restore_backup(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
):
"""Restore from a complete backup ZIP.
This is a simplified restore that replaces the database and all data directories
from the backup ZIP. Requires a restart after restore.
"""
import shutil
import tempfile
from fastapi import HTTPException
from backend.app.core.database import close_all_connections
base_dir = app_settings.base_dir
db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# 1. Read and extract ZIP
content = await file.read()
# Check if it's a valid ZIP
if not file.filename or not file.filename.endswith(".zip"):
raise HTTPException(400, "Invalid backup file: must be a .zip file")
try:
with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
zf.extractall(temp_path)
except zipfile.BadZipFile:
raise HTTPException(400, "Invalid backup file: not a valid ZIP")
# 2. Validate backup (must have database)
backup_db = temp_path / "bambuddy.db"
if not backup_db.exists():
raise HTTPException(400, "Invalid backup: missing bambuddy.db")
# 3. Close current database connections
await close_all_connections()
# 4. Replace database
shutil.copy2(backup_db, db_path)
# 5. Replace data directories
dirs_to_restore = [
("archive", base_dir / "archive"),
("virtual_printer", base_dir / "virtual_printer"),
("plate_calibration", app_settings.plate_calibration_dir),
("icons", base_dir / "icons"),
("projects", base_dir / "projects"),
]
for name, dest_dir in dirs_to_restore:
src_dir = temp_path / name
if src_dir.exists():
if dest_dir.exists():
shutil.rmtree(dest_dir)
shutil.copytree(src_dir, dest_dir)
# 6. Note: Database connection will be reinitialized on restart
# The application should be restarted after restore
return {
"success": True,
"message": "Backup restored successfully. Please restart Bambuddy for changes to take effect.",
}
@router.post("/restore-legacy")
async def import_backup_legacy(
file: UploadFile = File(...),
overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
db: AsyncSession = Depends(get_db),
):
"""Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
"""Legacy restore: Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
try:
content = await file.read()
base_dir = app_settings.base_dir
+20
View File
@@ -15,6 +15,26 @@ async_session = async_sessionmaker(
)
async def close_all_connections():
"""Close all database connections for backup/restore operations."""
global engine
await engine.dispose()
async def reinitialize_database():
"""Reinitialize database connection after restore."""
global engine, async_session
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
)
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
+7 -27
View File
@@ -2498,25 +2498,9 @@ export const api = {
getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),
resetSettings: () =>
request<AppSettings>('/settings/reset', { method: 'POST' }),
exportBackup: async (categories?: Record<string, boolean>): Promise<{ blob: Blob; filename: string }> => {
const params = new URLSearchParams();
if (categories) {
if (categories.settings !== undefined) params.set('include_settings', String(categories.settings));
if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications));
if (categories.templates !== undefined) params.set('include_templates', String(categories.templates));
if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));
if (categories.external_links !== undefined) params.set('include_external_links', String(categories.external_links));
if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));
if (categories.plate_calibration !== undefined) params.set('include_plate_calibration', String(categories.plate_calibration));
if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
if (categories.projects !== undefined) params.set('include_projects', String(categories.projects));
if (categories.pending_uploads !== undefined) params.set('include_pending_uploads', String(categories.pending_uploads));
if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
if (categories.api_keys !== undefined) params.set('include_api_keys', String(categories.api_keys));
}
const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
exportBackup: async (): Promise<{ blob: Blob; filename: string }> => {
// New simplified backup - complete database + all files
const url = `${API_BASE}/settings/backup`;
const response = await fetch(url);
// Check for errors
@@ -2527,7 +2511,7 @@ export const api = {
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'bambuddy-backup.json';
let filename = 'bambuddy-backup.zip';
if (contentDisposition) {
const match = contentDisposition.match(/filename=([^;]+)/);
if (match) filename = match[1].trim();
@@ -2536,10 +2520,11 @@ export const api = {
const blob = await response.blob();
return { blob, filename };
},
importBackup: async (file: File, overwrite = false) => {
importBackup: async (file: File) => {
// New simplified restore - replaces database + all directories
const formData = new FormData();
formData.append('file', file);
const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`;
const url = `${API_BASE}/settings/restore`;
const response = await fetch(url, {
method: 'POST',
body: formData,
@@ -2547,11 +2532,6 @@ export const api = {
return response.json() as Promise<{
success: boolean;
message: string;
restored?: Record<string, number>;
skipped?: Record<string, number>;
skipped_details?: Record<string, string[]>;
files_restored?: number;
total_skipped?: number;
}>;
},
checkFfmpeg: () =>
+169 -43
View File
@@ -16,6 +16,7 @@ import {
SkipForward,
AlertTriangle,
Trash2,
RotateCcw,
} from 'lucide-react';
import { api } from '../api/client';
import type {
@@ -31,8 +32,7 @@ import type {
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { BackupModal } from './BackupModal';
import { RestoreModal } from './RestoreModal';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
interface StatusBadgeProps {
@@ -108,9 +108,30 @@ export function GitHubBackupSettings() {
const [backupSettings, setBackupSettings] = useState(false);
const [enabled, setEnabled] = useState(true);
// Local backup modals
const [showBackupModal, setShowBackupModal] = useState(false);
const [showRestoreModal, setShowRestoreModal] = useState(false);
// Local backup state
const [isExporting, setIsExporting] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [operationStatus, setOperationStatus] = useState<string>('');
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Block navigation while backup/restore is in progress
useEffect(() => {
const isOperationInProgress = isExporting || isRestoring;
if (isOperationInProgress) {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?';
return e.returnValue;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}
}, [isExporting, isRestoring]);
// Test connection state
const [testLoading, setTestLoading] = useState(false);
@@ -696,80 +717,185 @@ export function GitHubBackupSettings() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
Export or import your Bambuddy data as a local file for manual backup or migration.
Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.
</p>
{/* Export */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">Export Data</p>
<p className="text-white">Download Backup</p>
<p className="text-sm text-bambu-gray">
Download all settings, printers, and profiles
Complete backup: database + all files (ZIP)
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowBackupModal(true)}
disabled={isExporting || isRestoring}
onClick={async () => {
setIsExporting(true);
setOperationStatus('Preparing backup...');
try {
setOperationStatus('Creating backup archive... This may take a while for large archives.');
const { blob, filename } = await api.exportBackup();
setOperationStatus('Downloading backup file...');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast('Backup downloaded successfully');
} catch (e) {
showToast(`Failed to create backup: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error');
} finally {
setIsExporting(false);
setOperationStatus('');
}
}}
>
<Download className="w-4 h-4" />
Export
Download
</Button>
</div>
<div className="flex items-center justify-between py-3">
{/* Import */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">Import Backup</p>
<p className="text-white">Restore Backup</p>
<p className="text-sm text-bambu-gray">
Restore from a previous export file
Replace all data from a backup file
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setRestoreFile(file);
setShowRestoreConfirm(true);
}
e.target.value = '';
}}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowRestoreModal(true)}
disabled={isRestoring || isExporting}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4" />
Import
Restore
</Button>
</div>
{/* Restore result message */}
{restoreResult && (
<div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-500/10 border border-green-500/30' : 'bg-red-500/10 border border-red-500/30'}`}>
<div className="flex items-start gap-2 text-sm">
{restoreResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
) : (
<XCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
)}
<div className={restoreResult.success ? 'text-green-200' : 'text-red-200'}>
{restoreResult.message}
{restoreResult.success && (
<div className="mt-2">
<Button
size="sm"
onClick={() => window.location.reload()}
>
<RotateCcw className="w-3 h-3" />
Reload Now
</Button>
</div>
)}
</div>
</div>
</div>
)}
{/* Warning */}
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-yellow-200">
<span className="font-medium">Restore replaces all data.</span>{' '}
<span className="text-yellow-200/70">Your current database and files will be completely replaced. A restart is required after restore.</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Modals */}
{showBackupModal && (
<BackupModal
onClose={() => setShowBackupModal(false)}
onExport={async (categories) => {
setShowBackupModal(false);
{/* Restore Confirmation Modal */}
{showRestoreConfirm && restoreFile && (
<ConfirmModal
title="Restore Backup"
message={`Are you sure you want to restore from "${restoreFile.name}"? This will completely replace your current database and all files. The application will need to be restarted after restore.`}
confirmText="Restore Backup"
variant="danger"
onConfirm={async () => {
setShowRestoreConfirm(false);
setIsRestoring(true);
setRestoreResult(null);
try {
const { blob, filename } = await api.exportBackup(categories);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast('Backup downloaded successfully');
} catch {
showToast('Failed to create backup', 'error');
setOperationStatus('Uploading backup file...');
const result = await api.importBackup(restoreFile);
setRestoreResult(result);
if (result.success) {
showToast('Backup restored. Please restart Bambuddy.', 'success');
} else {
showToast(result.message, 'error');
}
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to restore backup';
setRestoreResult({ success: false, message });
showToast(message, 'error');
} finally {
setIsRestoring(false);
setOperationStatus('');
setRestoreFile(null);
}
}}
onCancel={() => {
setShowRestoreConfirm(false);
setRestoreFile(null);
}}
/>
)}
{showRestoreModal && (
<RestoreModal
onClose={() => setShowRestoreModal(false)}
onRestore={async (file, overwrite) => {
return await api.importBackup(file, overwrite);
}}
onSuccess={() => {
setShowRestoreModal(false);
showToast('Backup restored successfully');
queryClient.invalidateQueries();
}}
/>
{/* Blocking overlay during backup/restore operations */}
{(isExporting || isRestoring) && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
<div className="flex justify-center mb-4">
<div className="relative">
<div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
<div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
</div>
</div>
<h3 className="text-xl font-semibold text-white mb-2">
{isExporting ? 'Creating Backup' : 'Restoring Backup'}
</h3>
<p className="text-bambu-gray mb-4">
{operationStatus || (isExporting ? 'Preparing...' : 'Processing...')}
</p>
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-yellow-200 text-left">
Please do not close this page or navigate away. This operation may take several minutes for large backups.
</p>
</div>
</div>
</div>
</div>
)}
</div>
);