Browse Source

- Improved backup/restore module

maziggy 5 months ago
parent
commit
3f9b7e0b7f

+ 48 - 7
backend/app/api/routes/settings.py

@@ -22,6 +22,7 @@ from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance,
 from backend.app.models.archive import PrintArchive
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
 
 
 router = APIRouter(prefix="/settings", tags=["settings"])
@@ -170,6 +171,7 @@ async def export_backup(
     include_filaments: bool = Query(False, description="Include filament inventory"),
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
     include_archives: bool = Query(False, description="Include print archive metadata"),
+    include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
 ):
     """Export selected data as JSON backup."""
     backup: dict = {
@@ -256,25 +258,29 @@ async def export_backup(
             })
         backup["included"].append("smart_plugs")
 
-    # Printers (without access codes for security)
+    # Printers (access codes only included if explicitly requested)
     if include_printers:
         result = await db.execute(select(Printer))
         printers = result.scalars().all()
         backup["printers"] = []
         for printer in printers:
-            backup["printers"].append({
+            printer_data = {
                 "name": printer.name,
                 "serial_number": printer.serial_number,
                 "ip_address": printer.ip_address,
-                # access_code intentionally excluded for security
                 "model": printer.model,
                 "location": printer.location,
                 "nozzle_count": printer.nozzle_count,
                 "is_active": printer.is_active,
                 "auto_archive": printer.auto_archive,
                 "print_hours_offset": printer.print_hours_offset,
-            })
+            }
+            if include_access_codes:
+                printer_data["access_code"] = printer.access_code
+            backup["printers"].append(printer_data)
         backup["included"].append("printers")
+        if include_access_codes:
+            backup["included"].append("access_codes")
 
     # Filaments
     if include_filaments:
@@ -501,7 +507,14 @@ async def import_backup(
     # Restore settings (always overwrites)
     if "settings" in backup:
         for key, value in backup["settings"].items():
-            await set_setting(db, key, value)
+            # Convert value to proper string format for storage
+            if isinstance(value, bool):
+                str_value = "true" if value else "false"
+            elif value is None:
+                str_value = "None"
+            else:
+                str_value = str(value)
+            await set_setting(db, key, str_value)
             restored["settings"] += 1
 
     # Restore notification providers (skip or overwrite duplicates by name)
@@ -662,15 +675,27 @@ async def import_backup(
                     skipped["printers"] += 1
                     skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
             else:
+                # Use access code from backup if provided, otherwise require manual setup
+                access_code = printer_data.get("access_code")
+                has_access_code = access_code and access_code != "CHANGE_ME"
+                is_active_from_backup = printer_data.get("is_active", False)
+                # Handle bool or string "true"/"false"
+                if isinstance(is_active_from_backup, str):
+                    is_active_from_backup = is_active_from_backup.lower() == "true"
+
+                import logging
+                logger = logging.getLogger(__name__)
+                logger.info(f"Restore: Creating printer {printer_data['name']}, has_access_code={has_access_code}, is_active={is_active_from_backup if has_access_code else False}")
+
                 printer = Printer(
                     name=printer_data["name"],
                     serial_number=printer_data["serial_number"],
                     ip_address=printer_data["ip_address"],
-                    access_code="CHANGE_ME",  # Must be set manually for security
+                    access_code=access_code if has_access_code else "CHANGE_ME",
                     model=printer_data.get("model"),
                     location=printer_data.get("location"),
                     nozzle_count=printer_data.get("nozzle_count", 1),
-                    is_active=False,  # Disabled until access_code is set
+                    is_active=is_active_from_backup if has_access_code else False,
                     auto_archive=printer_data.get("auto_archive", True),
                     print_hours_offset=printer_data.get("print_hours_offset", 0.0),
                 )
@@ -815,10 +840,26 @@ async def import_backup(
             select(Printer).where(Printer.is_active == True)
         )
         active_printers = result.scalars().all()
+        import logging
+        logger = logging.getLogger(__name__)
+        logger.info(f"Restore: Found {len(active_printers)} active printers to reconnect")
         for printer in active_printers:
+            logger.info(f"Restore: Reconnecting printer {printer.name} (id={printer.id}, ip={printer.ip_address})")
             # This will disconnect existing connection (if any) and reconnect
             await printer_manager.connect_printer(printer)
 
+    # If settings were restored, check if Spoolman needs to be reconnected
+    if "settings" in backup:
+        spoolman_enabled = await get_setting(db, "spoolman_enabled")
+        spoolman_url = await get_setting(db, "spoolman_url")
+        if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
+            try:
+                client = await init_spoolman_client(spoolman_url)
+                if await client.health_check():
+                    pass  # Connected successfully
+            except Exception:
+                pass  # Spoolman connection failed, but don't fail the restore
+
     # Build summary message
     restored_parts = []
     for key, count in restored.items():

+ 1 - 0
frontend/src/api/client.ts

@@ -1160,6 +1160,7 @@ export const api = {
       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.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
     }
     const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
     const response = await fetch(url);

+ 32 - 4
frontend/src/components/BackupModal.tsx

@@ -1,8 +1,9 @@
 import { useEffect, useState } from 'react';
-import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2 } from 'lucide-react';
+import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
+import { Toggle } from './Toggle';
 
 interface BackupCategory {
   id: string;
@@ -94,6 +95,7 @@ export function BackupModal({ onClose, onExport }: BackupModalProps) {
     });
     return initial;
   });
+  const [includeAccessCodes, setIncludeAccessCodes] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
 
   // Close on Escape key
@@ -130,7 +132,7 @@ export function BackupModal({ onClose, onExport }: BackupModalProps) {
   const handleExport = async () => {
     setIsExporting(true);
     try {
-      await onExport(selected);
+      await onExport({ ...selected, access_codes: includeAccessCodes && selected.printers });
     } finally {
       setIsExporting(false);
     }
@@ -221,14 +223,40 @@ export function BackupModal({ onClose, onExport }: BackupModalProps) {
             <div className="mx-4 mb-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
               <div className="flex items-start gap-2 text-sm">
                 <Archive className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
-                <div className="text-yellow-200">
+                <div className="text-yellow-200 dark:text-yellow-200 text-yellow-700">
                   <span className="font-medium">ZIP file will be created.</span>
-                  <span className="text-yellow-200/70"> Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.</span>
+                  <span className="text-yellow-600 dark:text-yellow-200/70"> Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.</span>
                 </div>
               </div>
             </div>
           )}
 
+          {/* Access codes option - only shown when printers are selected */}
+          {selected.printers && (
+            <div className="mx-4 mb-2 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+              <div className="flex items-center justify-between">
+                <div className="flex items-start gap-2">
+                  <Key className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
+                  <div>
+                    <p className="text-sm font-medium text-white">Include Access Codes</p>
+                    <p className="text-xs text-bambu-gray">For transferring to another machine</p>
+                  </div>
+                </div>
+                <Toggle checked={includeAccessCodes} onChange={setIncludeAccessCodes} />
+              </div>
+              {includeAccessCodes && (
+                <div className="mt-2 p-2 rounded bg-orange-500/10 border border-orange-500/30">
+                  <div className="flex items-start gap-2 text-xs">
+                    <AlertTriangle className="w-3 h-3 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
+                    <span className="text-orange-700 dark:text-orange-200">
+                      Access codes will be included in plain text. Keep this backup file secure!
+                    </span>
+                  </div>
+                </div>
+              )}
+            </div>
+          )}
+
           {/* Footer */}
           <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
             <span className="text-sm text-bambu-gray">

+ 10 - 3
frontend/src/components/RestoreModal.tsx

@@ -96,9 +96,14 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
   return (
     <div
       className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={state !== 'restoring' ? onClose : undefined}
+      onMouseDown={(e) => {
+        // Only close if clicking directly on the backdrop, not on children
+        if (e.target === e.currentTarget && state !== 'restoring') {
+          onClose();
+        }
+      }}
     >
-      <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+      <Card className="w-full max-w-lg">
         <CardContent className="p-0">
           {/* Header */}
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
@@ -155,6 +160,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
                     onChange={handleFileSelect}
                   />
                   <button
+                    type="button"
                     onClick={() => fileInputRef.current?.click()}
                     className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
                       selectedFile
@@ -230,10 +236,11 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
 
               {/* Footer */}
               <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
-                <Button variant="secondary" onClick={onClose}>
+                <Button type="button" variant="secondary" onClick={onClose}>
                   Cancel
                 </Button>
                 <Button
+                  type="button"
                   onClick={handleRestore}
                   disabled={!selectedFile}
                   className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50"

+ 21 - 24
frontend/src/components/SpoolmanSettings.tsx

@@ -37,9 +37,9 @@ export function SpoolmanSettings() {
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localUrl, setLocalUrl] = useState('');
   const [localSyncMode, setLocalSyncMode] = useState('auto');
-  const [hasChanges, setHasChanges] = useState(false);
   const [showSaved, setShowSaved] = useState(false);
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
+  const [isInitialized, setIsInitialized] = useState(false);
 
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
@@ -66,19 +66,26 @@ export function SpoolmanSettings() {
       setLocalEnabled(settings.spoolman_enabled === 'true');
       setLocalUrl(settings.spoolman_url || '');
       setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
+      setIsInitialized(true);
     }
   }, [settings]);
 
-  // Track changes
+  // Auto-save when settings change (after initial load)
   useEffect(() => {
-    if (settings) {
-      const changed =
-        (settings.spoolman_enabled === 'true') !== localEnabled ||
-        (settings.spoolman_url || '') !== localUrl ||
-        (settings.spoolman_sync_mode || 'auto') !== localSyncMode;
-      setHasChanges(changed);
+    if (!isInitialized || !settings) return;
+
+    const hasChanges =
+      (settings.spoolman_enabled === 'true') !== localEnabled ||
+      (settings.spoolman_url || '') !== localUrl ||
+      (settings.spoolman_sync_mode || 'auto') !== localSyncMode;
+
+    if (hasChanges) {
+      const timeoutId = setTimeout(() => {
+        saveMutation.mutate();
+      }, 500);
+      return () => clearTimeout(timeoutId);
     }
-  }, [settings, localEnabled, localUrl, localSyncMode]);
+  }, [localEnabled, localUrl, localSyncMode, isInitialized]);
 
   // Save mutation
   const saveMutation = useMutation({
@@ -91,7 +98,6 @@ export function SpoolmanSettings() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
-      setHasChanges(false);
       setShowSaved(true);
       setTimeout(() => setShowSaved(false), 2000);
     },
@@ -173,20 +179,11 @@ export function SpoolmanSettings() {
             <Database className="w-5 h-5 text-bambu-green" />
             <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
           </div>
-          {hasChanges && (
-            <Button
-              size="sm"
-              onClick={() => saveMutation.mutate()}
-              disabled={saveMutation.isPending}
-            >
-              {saveMutation.isPending ? (
-                <Loader2 className="w-4 h-4 animate-spin" />
-              ) : showSaved ? (
-                <Check className="w-4 h-4" />
-              ) : (
-                'Save'
-              )}
-            </Button>
+          {saveMutation.isPending && (
+            <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
+          )}
+          {showSaved && (
+            <Check className="w-4 h-4 text-bambu-green" />
           )}
         </div>
       </CardHeader>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BEeIB2d_.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Ob3MFXab.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-p0QddWWP.js


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-Dyo2PpHq.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BEeIB2d_.css">
+    <script type="module" crossorigin src="/assets/index-p0QddWWP.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Ob3MFXab.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff