diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 5b0a3dd45..4dd219525 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -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 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0ab95b10b..688797b79 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5ad78680d..a67bdc315 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2498,25 +2498,9 @@ export const api = { getMQTTStatus: () => request('/settings/mqtt/status'), resetSettings: () => request('/settings/reset', { method: 'POST' }), - exportBackup: async (categories?: Record): 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; - skipped?: Record; - skipped_details?: Record; - files_restored?: number; - total_skipped?: number; }>; }, checkFfmpeg: () => diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index 5c00a8b57..97c2b7580 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/frontend/src/components/GitHubBackupSettings.tsx @@ -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(''); + const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); + const [restoreFile, setRestoreFile] = useState(null); + const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null); + const fileInputRef = useRef(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() {

- 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.

+ {/* Export */}
-

Export Data

+

Download Backup

- Download all settings, printers, and profiles + Complete backup: database + all files (ZIP)

-
+ {/* Import */} +
-

Import Backup

+

Restore Backup

- Restore from a previous export file + Replace all data from a backup file

+ { + const file = e.target.files?.[0]; + if (file) { + setRestoreFile(file); + setShowRestoreConfirm(true); + } + e.target.value = ''; + }} + />
+ + {/* Restore result message */} + {restoreResult && ( +
+
+ {restoreResult.success ? ( + + ) : ( + + )} +
+ {restoreResult.message} + {restoreResult.success && ( +
+ +
+ )} +
+
+
+ )} + + {/* Warning */} +
+
+ +
+ Restore replaces all data.{' '} + Your current database and files will be completely replaced. A restart is required after restore. +
+
+
- {/* Modals */} - {showBackupModal && ( - setShowBackupModal(false)} - onExport={async (categories) => { - setShowBackupModal(false); + {/* Restore Confirmation Modal */} + {showRestoreConfirm && restoreFile && ( + { + 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 && ( - 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) && ( +
+
+
+
+
+
+
+
+

+ {isExporting ? 'Creating Backup' : 'Restoring Backup'} +

+

+ {operationStatus || (isExporting ? 'Preparing...' : 'Processing...')} +

+
+
+ +

+ Please do not close this page or navigate away. This operation may take several minutes for large backups. +

+
+
+
+
)} );