Browse Source

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
maziggy 3 months ago
parent
commit
e1ff8f19e7

+ 133 - 3
backend/app/api/routes/settings.py

@@ -238,7 +238,65 @@ async def update_spoolman_settings(
 
 
 
 
 @router.get("/backup")
 @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),
     db: AsyncSession = Depends(get_db),
     include_settings: bool = Query(True, description="Include app settings"),
     include_settings: bool = Query(True, description="Include app settings"),
     include_notifications: bool = Query(True, description="Include notification providers"),
     include_notifications: bool = Query(True, description="Include notification providers"),
@@ -953,12 +1011,84 @@ async def export_backup(
 
 
 
 
 @router.post("/restore")
 @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(...),
     file: UploadFile = File(...),
     overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
     overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
     db: AsyncSession = Depends(get_db),
     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:
     try:
         content = await file.read()
         content = await file.read()
         base_dir = app_settings.base_dir
         base_dir = app_settings.base_dir

+ 20 - 0
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):
 class Base(DeclarativeBase):
     pass
     pass
 
 

+ 7 - 27
frontend/src/api/client.ts

@@ -2498,25 +2498,9 @@ export const api = {
   getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),
   getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),
   resetSettings: () =>
   resetSettings: () =>
     request<AppSettings>('/settings/reset', { method: 'POST' }),
     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);
     const response = await fetch(url);
 
 
     // Check for errors
     // Check for errors
@@ -2527,7 +2511,7 @@ export const api = {
 
 
     // Get filename from Content-Disposition header
     // Get filename from Content-Disposition header
     const contentDisposition = response.headers.get('Content-Disposition');
     const contentDisposition = response.headers.get('Content-Disposition');
-    let filename = 'bambuddy-backup.json';
+    let filename = 'bambuddy-backup.zip';
     if (contentDisposition) {
     if (contentDisposition) {
       const match = contentDisposition.match(/filename=([^;]+)/);
       const match = contentDisposition.match(/filename=([^;]+)/);
       if (match) filename = match[1].trim();
       if (match) filename = match[1].trim();
@@ -2536,10 +2520,11 @@ export const api = {
     const blob = await response.blob();
     const blob = await response.blob();
     return { blob, filename };
     return { blob, filename };
   },
   },
-  importBackup: async (file: File, overwrite = false) => {
+  importBackup: async (file: File) => {
+    // New simplified restore - replaces database + all directories
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
-    const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`;
+    const url = `${API_BASE}/settings/restore`;
     const response = await fetch(url, {
     const response = await fetch(url, {
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,
@@ -2547,11 +2532,6 @@ export const api = {
     return response.json() as Promise<{
     return response.json() as Promise<{
       success: boolean;
       success: boolean;
       message: string;
       message: string;
-      restored?: Record<string, number>;
-      skipped?: Record<string, number>;
-      skipped_details?: Record<string, string[]>;
-      files_restored?: number;
-      total_skipped?: number;
     }>;
     }>;
   },
   },
   checkFfmpeg: () =>
   checkFfmpeg: () =>

+ 169 - 43
frontend/src/components/GitHubBackupSettings.tsx

@@ -16,6 +16,7 @@ import {
   SkipForward,
   SkipForward,
   AlertTriangle,
   AlertTriangle,
   Trash2,
   Trash2,
+  RotateCcw,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -31,8 +32,7 @@ import type {
 import { Card, CardContent, CardHeader } from './Card';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
-import { BackupModal } from './BackupModal';
-import { RestoreModal } from './RestoreModal';
+import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 interface StatusBadgeProps {
 interface StatusBadgeProps {
@@ -108,9 +108,30 @@ export function GitHubBackupSettings() {
   const [backupSettings, setBackupSettings] = useState(false);
   const [backupSettings, setBackupSettings] = useState(false);
   const [enabled, setEnabled] = useState(true);
   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
   // Test connection state
   const [testLoading, setTestLoading] = useState(false);
   const [testLoading, setTestLoading] = useState(false);
@@ -696,80 +717,185 @@ export function GitHubBackupSettings() {
           </CardHeader>
           </CardHeader>
           <CardContent className="space-y-4">
           <CardContent className="space-y-4">
             <p className="text-sm text-bambu-gray">
             <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>
             </p>
 
 
+            {/* Export */}
             <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
             <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
               <div>
               <div>
-                <p className="text-white">Export Data</p>
+                <p className="text-white">Download Backup</p>
                 <p className="text-sm text-bambu-gray">
                 <p className="text-sm text-bambu-gray">
-                  Download all settings, printers, and profiles
+                  Complete backup: database + all files (ZIP)
                 </p>
                 </p>
               </div>
               </div>
               <Button
               <Button
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 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" />
                 <Download className="w-4 h-4" />
-                Export
+                Download
               </Button>
               </Button>
             </div>
             </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>
               <div>
-                <p className="text-white">Import Backup</p>
+                <p className="text-white">Restore Backup</p>
                 <p className="text-sm text-bambu-gray">
                 <p className="text-sm text-bambu-gray">
-                  Restore from a previous export file
+                  Replace all data from a backup file
                 </p>
                 </p>
               </div>
               </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
               <Button
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
-                onClick={() => setShowRestoreModal(true)}
+                disabled={isRestoring || isExporting}
+                onClick={() => fileInputRef.current?.click()}
               >
               >
                 <Upload className="w-4 h-4" />
                 <Upload className="w-4 h-4" />
-                Import
+                Restore
               </Button>
               </Button>
             </div>
             </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>
           </CardContent>
         </Card>
         </Card>
       </div>
       </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 {
             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>
     </div>
   );
   );