Просмотр исходного кода

- Improved backup/restore module

maziggy 5 месяцев назад
Родитель
Сommit
fb6ff3130e

+ 142 - 17
backend/app/api/routes/settings.py

@@ -427,9 +427,10 @@ async def export_backup(
 @router.post("/restore")
 @router.post("/restore")
 async def import_backup(
 async def import_backup(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
+    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. Skips duplicates."""
+    """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
@@ -477,21 +478,64 @@ async def import_backup(
         "filaments": 0,
         "filaments": 0,
         "maintenance_types": 0,
         "maintenance_types": 0,
     }
     }
+    skipped = {
+        "settings": 0,
+        "notification_providers": 0,
+        "notification_templates": 0,
+        "smart_plugs": 0,
+        "printers": 0,
+        "filaments": 0,
+        "maintenance_types": 0,
+        "archives": 0,
+    }
+    skipped_details = {
+        "notification_providers": [],
+        "smart_plugs": [],
+        "printers": [],
+        "filaments": [],
+        "maintenance_types": [],
+        "archives": [],
+    }
 
 
-    # Restore settings
+    # Restore settings (always overwrites)
     if "settings" in backup:
     if "settings" in backup:
         for key, value in backup["settings"].items():
         for key, value in backup["settings"].items():
             await set_setting(db, key, value)
             await set_setting(db, key, value)
             restored["settings"] += 1
             restored["settings"] += 1
 
 
-    # Restore notification providers (skip duplicates by name)
+    # Restore notification providers (skip or overwrite duplicates by name)
     if "notification_providers" in backup:
     if "notification_providers" in backup:
         for provider_data in backup["notification_providers"]:
         for provider_data in backup["notification_providers"]:
             result = await db.execute(
             result = await db.execute(
                 select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
                 select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
             )
             )
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
-            if not existing:
+            if existing:
+                if overwrite:
+                    # Update existing provider
+                    existing.provider_type = provider_data["provider_type"]
+                    existing.enabled = provider_data.get("enabled", True)
+                    existing.config = json.dumps(provider_data.get("config", {}))
+                    existing.on_print_start = provider_data.get("on_print_start", False)
+                    existing.on_print_complete = provider_data.get("on_print_complete", True)
+                    existing.on_print_failed = provider_data.get("on_print_failed", True)
+                    existing.on_print_stopped = provider_data.get("on_print_stopped", True)
+                    existing.on_print_progress = provider_data.get("on_print_progress", False)
+                    existing.on_printer_offline = provider_data.get("on_printer_offline", False)
+                    existing.on_printer_error = provider_data.get("on_printer_error", False)
+                    existing.on_filament_low = provider_data.get("on_filament_low", False)
+                    existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
+                    existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
+                    existing.quiet_hours_start = provider_data.get("quiet_hours_start")
+                    existing.quiet_hours_end = provider_data.get("quiet_hours_end")
+                    existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
+                    existing.daily_digest_time = provider_data.get("daily_digest_time")
+                    existing.printer_id = provider_data.get("printer_id")
+                    restored["notification_providers"] += 1
+                else:
+                    skipped["notification_providers"] += 1
+                    skipped_details["notification_providers"].append(provider_data["name"])
+            else:
                 provider = NotificationProvider(
                 provider = NotificationProvider(
                     name=provider_data["name"],
                     name=provider_data["name"],
                     provider_type=provider_data["provider_type"],
                     provider_type=provider_data["provider_type"],
@@ -542,14 +586,36 @@ async def import_backup(
                 db.add(template)
                 db.add(template)
             restored["notification_templates"] += 1
             restored["notification_templates"] += 1
 
 
-    # Restore smart plugs (skip duplicates by IP)
+    # Restore smart plugs (skip or overwrite duplicates by IP)
     if "smart_plugs" in backup:
     if "smart_plugs" in backup:
         for plug_data in backup["smart_plugs"]:
         for plug_data in backup["smart_plugs"]:
             result = await db.execute(
             result = await db.execute(
                 select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
                 select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
             )
             )
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
-            if not existing:
+            if existing:
+                if overwrite:
+                    existing.name = plug_data["name"]
+                    existing.printer_id = plug_data.get("printer_id")
+                    existing.enabled = plug_data.get("enabled", True)
+                    existing.auto_on = plug_data.get("auto_on", True)
+                    existing.auto_off = plug_data.get("auto_off", True)
+                    existing.off_delay_mode = plug_data.get("off_delay_mode", "time")
+                    existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5)
+                    existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70)
+                    existing.username = plug_data.get("username")
+                    existing.password = plug_data.get("password")
+                    existing.power_alert_enabled = plug_data.get("power_alert_enabled", False)
+                    existing.power_alert_high = plug_data.get("power_alert_high")
+                    existing.power_alert_low = plug_data.get("power_alert_low")
+                    existing.schedule_enabled = plug_data.get("schedule_enabled", False)
+                    existing.schedule_on_time = plug_data.get("schedule_on_time")
+                    existing.schedule_off_time = plug_data.get("schedule_off_time")
+                    restored["smart_plugs"] += 1
+                else:
+                    skipped["smart_plugs"] += 1
+                    skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_data['ip_address']})")
+            else:
                 plug = SmartPlug(
                 plug = SmartPlug(
                     name=plug_data["name"],
                     name=plug_data["name"],
                     ip_address=plug_data["ip_address"],
                     ip_address=plug_data["ip_address"],
@@ -572,14 +638,29 @@ async def import_backup(
                 db.add(plug)
                 db.add(plug)
                 restored["smart_plugs"] += 1
                 restored["smart_plugs"] += 1
 
 
-    # Restore printers (skip duplicates by serial_number, requires access_code to be set manually)
+    # Restore printers (skip or overwrite duplicates by serial_number)
+    # Note: access_code is never restored for security - must be set manually
     if "printers" in backup:
     if "printers" in backup:
         for printer_data in backup["printers"]:
         for printer_data in backup["printers"]:
             result = await db.execute(
             result = await db.execute(
                 select(Printer).where(Printer.serial_number == printer_data["serial_number"])
                 select(Printer).where(Printer.serial_number == printer_data["serial_number"])
             )
             )
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
-            if not existing:
+            if existing:
+                if overwrite:
+                    existing.name = printer_data["name"]
+                    existing.ip_address = printer_data["ip_address"]
+                    existing.model = printer_data.get("model")
+                    existing.location = printer_data.get("location")
+                    existing.nozzle_count = printer_data.get("nozzle_count", 1)
+                    existing.auto_archive = printer_data.get("auto_archive", True)
+                    existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
+                    # Don't overwrite access_code or is_active to preserve working connection
+                    restored["printers"] += 1
+                else:
+                    skipped["printers"] += 1
+                    skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
+            else:
                 printer = Printer(
                 printer = Printer(
                     name=printer_data["name"],
                     name=printer_data["name"],
                     serial_number=printer_data["serial_number"],
                     serial_number=printer_data["serial_number"],
@@ -595,7 +676,7 @@ async def import_backup(
                 db.add(printer)
                 db.add(printer)
                 restored["printers"] += 1
                 restored["printers"] += 1
 
 
-    # Restore filaments (skip duplicates by name+type+brand)
+    # Restore filaments (skip or overwrite duplicates by name+type+brand)
     if "filaments" in backup:
     if "filaments" in backup:
         for filament_data in backup["filaments"]:
         for filament_data in backup["filaments"]:
             result = await db.execute(
             result = await db.execute(
@@ -606,7 +687,23 @@ async def import_backup(
                 )
                 )
             )
             )
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
-            if not existing:
+            if existing:
+                if overwrite:
+                    existing.color = filament_data.get("color")
+                    existing.color_hex = filament_data.get("color_hex")
+                    existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0)
+                    existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0)
+                    existing.currency = filament_data.get("currency", "USD")
+                    existing.density = filament_data.get("density")
+                    existing.print_temp_min = filament_data.get("print_temp_min")
+                    existing.print_temp_max = filament_data.get("print_temp_max")
+                    existing.bed_temp_min = filament_data.get("bed_temp_min")
+                    existing.bed_temp_max = filament_data.get("bed_temp_max")
+                    restored["filaments"] += 1
+                else:
+                    skipped["filaments"] += 1
+                    skipped_details["filaments"].append(f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})")
+            else:
                 filament = Filament(
                 filament = Filament(
                     name=filament_data["name"],
                     name=filament_data["name"],
                     type=filament_data["type"],
                     type=filament_data["type"],
@@ -625,14 +722,25 @@ async def import_backup(
                 db.add(filament)
                 db.add(filament)
                 restored["filaments"] += 1
                 restored["filaments"] += 1
 
 
-    # Restore maintenance types (skip duplicates by name)
+    # Restore maintenance types (skip or overwrite duplicates by name)
     if "maintenance_types" in backup:
     if "maintenance_types" in backup:
         for mt_data in backup["maintenance_types"]:
         for mt_data in backup["maintenance_types"]:
             result = await db.execute(
             result = await db.execute(
                 select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])
                 select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])
             )
             )
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
-            if not existing:
+            if existing:
+                if overwrite:
+                    existing.description = mt_data.get("description")
+                    existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0)
+                    existing.interval_type = mt_data.get("interval_type", "hours")
+                    existing.icon = mt_data.get("icon")
+                    # Don't overwrite is_system
+                    restored["maintenance_types"] += 1
+                else:
+                    skipped["maintenance_types"] += 1
+                    skipped_details["maintenance_types"].append(mt_data["name"])
+            else:
                 mt = MaintenanceType(
                 mt = MaintenanceType(
                     name=mt_data["name"],
                     name=mt_data["name"],
                     description=mt_data.get("description"),
                     description=mt_data.get("description"),
@@ -644,7 +752,7 @@ async def import_backup(
                 db.add(mt)
                 db.add(mt)
                 restored["maintenance_types"] += 1
                 restored["maintenance_types"] += 1
 
 
-    # Restore archives (skip duplicates by content_hash)
+    # Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
     if "archives" in backup:
     if "archives" in backup:
         for archive_data in backup["archives"]:
         for archive_data in backup["archives"]:
             # Skip if no content_hash or already exists
             # Skip if no content_hash or already exists
@@ -655,6 +763,8 @@ async def import_backup(
                 )
                 )
                 existing = result.scalar_one_or_none()
                 existing = result.scalar_one_or_none()
                 if existing:
                 if existing:
+                    skipped["archives"] += 1
+                    skipped_details["archives"].append(archive_data.get("filename", "Unknown"))
                     continue
                     continue
 
 
             # Only restore if file exists (from ZIP extraction)
             # Only restore if file exists (from ZIP extraction)
@@ -697,17 +807,32 @@ async def import_backup(
     await db.commit()
     await db.commit()
 
 
     # Build summary message
     # Build summary message
-    parts = []
+    restored_parts = []
     for key, count in restored.items():
     for key, count in restored.items():
         if count > 0:
         if count > 0:
-            parts.append(f"{count} {key.replace('_', ' ')}")
+            restored_parts.append(f"{count} {key.replace('_', ' ')}")
 
 
     if files_restored > 0:
     if files_restored > 0:
-        parts.append(f"{files_restored} files")
+        restored_parts.append(f"{files_restored} files")
+
+    skipped_parts = []
+    total_skipped = sum(skipped.values())
+    for key, count in skipped.items():
+        if count > 0:
+            skipped_parts.append(f"{count} {key.replace('_', ' ')}")
+
+    message_parts = []
+    if restored_parts:
+        message_parts.append(f"Restored: {', '.join(restored_parts)}")
+    if skipped_parts:
+        message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
 
 
     return {
     return {
         "success": True,
         "success": True,
-        "message": f"Restored: {', '.join(parts)}" if parts else "Nothing to restore",
+        "message": ". ".join(message_parts) if message_parts else "Nothing to restore",
         "restored": restored,
         "restored": restored,
+        "skipped": skipped,
+        "skipped_details": skipped_details,
         "files_restored": files_restored,
         "files_restored": files_restored,
+        "total_skipped": total_skipped,
     }
     }

+ 8 - 3
frontend/src/api/client.ts

@@ -1175,17 +1175,22 @@ export const api = {
     const blob = await response.blob();
     const blob = await response.blob();
     return { blob, filename };
     return { blob, filename };
   },
   },
-  importBackup: async (file: File) => {
+  importBackup: async (file: File, overwrite = false) => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
-    const response = await fetch(`${API_BASE}/settings/restore`, {
+    const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`;
+    const response = await fetch(url, {
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,
     });
     });
     return response.json() as Promise<{
     return response.json() as Promise<{
       success: boolean;
       success: boolean;
       message: string;
       message: string;
-      restored?: { settings: number; notification_providers: number; smart_plugs: number };
+      restored?: Record<string, number>;
+      skipped?: Record<string, number>;
+      skipped_details?: Record<string, string[]>;
+      files_restored?: number;
+      total_skipped?: number;
     }>;
     }>;
   },
   },
   checkFfmpeg: () =>
   checkFfmpeg: () =>

+ 363 - 0
frontend/src/components/RestoreModal.tsx

@@ -0,0 +1,363 @@
+import { useState, useRef, useEffect } from 'react';
+import { Upload, X, AlertTriangle, CheckCircle, SkipForward, RefreshCw, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+
+interface RestoreResult {
+  success: boolean;
+  message: string;
+  restored?: Record<string, number>;
+  skipped?: Record<string, number>;
+  skipped_details?: Record<string, string[]>;
+  files_restored?: number;
+  total_skipped?: number;
+}
+
+interface RestoreModalProps {
+  onClose: () => void;
+  onRestore: (file: File, overwrite: boolean) => Promise<RestoreResult>;
+  onSuccess: () => void;
+}
+
+type ModalState = 'options' | 'restoring' | 'result';
+
+const CATEGORY_LABELS: Record<string, string> = {
+  settings: 'Settings',
+  notification_providers: 'Notification Providers',
+  notification_templates: 'Notification Templates',
+  smart_plugs: 'Smart Plugs',
+  printers: 'Printers',
+  filaments: 'Filaments',
+  maintenance_types: 'Maintenance Types',
+  archives: 'Archives',
+};
+
+export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
+  const [state, setState] = useState<ModalState>('options');
+  const [overwrite, setOverwrite] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<File | null>(null);
+  const [result, setResult] = useState<RestoreResult | null>(null);
+  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && state !== 'restoring') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, state]);
+
+  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (file) {
+      setSelectedFile(file);
+    }
+  };
+
+  const handleRestore = async () => {
+    if (!selectedFile) return;
+
+    setState('restoring');
+    try {
+      const restoreResult = await onRestore(selectedFile, overwrite);
+      setResult(restoreResult);
+      setState('result');
+      if (restoreResult.success) {
+        onSuccess();
+      }
+    } catch {
+      setResult({
+        success: false,
+        message: 'Failed to restore backup. Please check the file format.',
+      });
+      setState('result');
+    }
+  };
+
+  const toggleCategory = (category: string) => {
+    setExpandedCategories(prev => {
+      const next = new Set(prev);
+      if (next.has(category)) {
+        next.delete(category);
+      } else {
+        next.add(category);
+      }
+      return next;
+    });
+  };
+
+  const totalRestored = result?.restored
+    ? Object.values(result.restored).reduce((a, b) => a + b, 0) + (result.files_restored || 0)
+    : 0;
+  const totalSkipped = result?.total_skipped || 0;
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={state !== 'restoring' ? onClose : undefined}
+    >
+      <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+            <div className="flex items-center gap-3">
+              <div className={`p-2 rounded-full ${
+                state === 'result' && result?.success
+                  ? 'bg-bambu-green/20 text-bambu-green'
+                  : state === 'result' && !result?.success
+                  ? 'bg-red-500/20 text-red-500'
+                  : 'bg-blue-500/20 text-blue-500'
+              }`}>
+                {state === 'result' && result?.success ? (
+                  <CheckCircle className="w-5 h-5" />
+                ) : state === 'result' && !result?.success ? (
+                  <AlertTriangle className="w-5 h-5" />
+                ) : (
+                  <Upload className="w-5 h-5" />
+                )}
+              </div>
+              <div>
+                <h3 className="text-lg font-semibold text-white">
+                  {state === 'options' && 'Restore Backup'}
+                  {state === 'restoring' && 'Restoring...'}
+                  {state === 'result' && (result?.success ? 'Restore Complete' : 'Restore Failed')}
+                </h3>
+                <p className="text-sm text-bambu-gray">
+                  {state === 'options' && 'Import settings from a backup file'}
+                  {state === 'restoring' && 'Please wait while your data is being restored'}
+                  {state === 'result' && result?.message}
+                </p>
+              </div>
+            </div>
+            {state !== 'restoring' && (
+              <button
+                onClick={onClose}
+                className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            )}
+          </div>
+
+          {/* Options State */}
+          {state === 'options' && (
+            <>
+              <div className="p-4 space-y-4">
+                {/* File Selection */}
+                <div>
+                  <input
+                    ref={fileInputRef}
+                    type="file"
+                    accept=".json,.zip"
+                    className="hidden"
+                    onChange={handleFileSelect}
+                  />
+                  <button
+                    onClick={() => fileInputRef.current?.click()}
+                    className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
+                      selectedFile
+                        ? 'border-bambu-green bg-bambu-green/10'
+                        : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                    }`}
+                  >
+                    {selectedFile ? (
+                      <div className="flex items-center justify-center gap-2 text-bambu-green">
+                        <CheckCircle className="w-5 h-5" />
+                        <span className="font-medium">{selectedFile.name}</span>
+                      </div>
+                    ) : (
+                      <div className="flex flex-col items-center gap-2 text-bambu-gray">
+                        <Upload className="w-8 h-8" />
+                        <span>Click to select backup file (.json or .zip)</span>
+                      </div>
+                    )}
+                  </button>
+                </div>
+
+                {/* Info Box */}
+                <div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
+                  <div className="flex items-start gap-2 text-sm">
+                    <AlertTriangle className="w-4 h-4 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" />
+                    <div className="text-blue-700 dark:text-blue-200">
+                      <p className="font-medium mb-1">How duplicate handling works:</p>
+                      <ul className="text-blue-600 dark:text-blue-200/80 space-y-1 text-xs">
+                        <li><strong>Printers</strong> - matched by serial number</li>
+                        <li><strong>Smart Plugs</strong> - matched by IP address</li>
+                        <li><strong>Notification Providers</strong> - matched by name</li>
+                        <li><strong>Filaments</strong> - matched by name + type + brand</li>
+                        <li><strong>Archives</strong> - matched by content hash (always skipped)</li>
+                        <li><strong>Settings & Templates</strong> - always overwritten</li>
+                      </ul>
+                    </div>
+                  </div>
+                </div>
+
+                {/* Overwrite Toggle */}
+                <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-white font-medium flex items-center gap-2">
+                        {overwrite ? (
+                          <RefreshCw className="w-4 h-4 text-orange-400" />
+                        ) : (
+                          <SkipForward className="w-4 h-4 text-bambu-gray" />
+                        )}
+                        {overwrite ? 'Overwrite existing data' : 'Skip duplicates'}
+                      </p>
+                      <p className="text-sm text-bambu-gray mt-1">
+                        {overwrite
+                          ? 'Replace existing items with data from backup (except access codes)'
+                          : 'Keep existing items, only add new ones from backup'}
+                      </p>
+                    </div>
+                    <Toggle checked={overwrite} onChange={setOverwrite} />
+                  </div>
+                </div>
+
+                {overwrite && (
+                  <div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
+                    <div className="flex items-start gap-2 text-sm">
+                      <AlertTriangle className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
+                      <div className="text-orange-700 dark:text-orange-200">
+                        <span className="font-medium">Caution:</span> Overwriting will replace your current configurations with data from the backup. Printer access codes are never overwritten for security.
+                      </div>
+                    </div>
+                  </div>
+                )}
+              </div>
+
+              {/* Footer */}
+              <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
+                <Button variant="secondary" onClick={onClose}>
+                  Cancel
+                </Button>
+                <Button
+                  onClick={handleRestore}
+                  disabled={!selectedFile}
+                  className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50"
+                >
+                  <Upload className="w-4 h-4 mr-2" />
+                  Restore
+                </Button>
+              </div>
+            </>
+          )}
+
+          {/* Restoring State */}
+          {state === 'restoring' && (
+            <div className="p-8 flex flex-col items-center gap-4">
+              <Loader2 className="w-12 h-12 text-bambu-green animate-spin" />
+              <p className="text-bambu-gray">Processing backup file...</p>
+            </div>
+          )}
+
+          {/* Result State */}
+          {state === 'result' && result && (
+            <>
+              <div className="p-4 space-y-4 max-h-[400px] overflow-y-auto">
+                {/* Summary */}
+                <div className="grid grid-cols-2 gap-3">
+                  <div className="p-3 rounded-lg bg-bambu-green/10 border border-bambu-green/30">
+                    <div className="text-2xl font-bold text-bambu-green">{totalRestored}</div>
+                    <div className="text-sm text-bambu-gray">Items Restored</div>
+                  </div>
+                  <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
+                    <div className="text-2xl font-bold text-yellow-500">{totalSkipped}</div>
+                    <div className="text-sm text-bambu-gray">Items Skipped</div>
+                  </div>
+                </div>
+
+                {/* Restored Details */}
+                {result.restored && Object.entries(result.restored).some(([, count]) => count > 0) && (
+                  <div className="space-y-2">
+                    <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
+                      <CheckCircle className="w-4 h-4 text-bambu-green" />
+                      Restored
+                    </h4>
+                    <div className="space-y-1">
+                      {Object.entries(result.restored)
+                        .filter(([, count]) => count > 0)
+                        .map(([key, count]) => (
+                          <div key={key} className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
+                            <span className="text-white">{CATEGORY_LABELS[key] || key}</span>
+                            <span className="text-bambu-green font-medium">{count}</span>
+                          </div>
+                        ))}
+                      {(result.files_restored || 0) > 0 && (
+                        <div className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
+                          <span className="text-white">Files (3MF, thumbnails, etc.)</span>
+                          <span className="text-bambu-green font-medium">{result.files_restored}</span>
+                        </div>
+                      )}
+                    </div>
+                  </div>
+                )}
+
+                {/* Skipped Details */}
+                {result.skipped && Object.entries(result.skipped).some(([, count]) => count > 0) && (
+                  <div className="space-y-2">
+                    <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
+                      <SkipForward className="w-4 h-4 text-yellow-500" />
+                      Skipped (already exist)
+                    </h4>
+                    <div className="space-y-1">
+                      {Object.entries(result.skipped)
+                        .filter(([, count]) => count > 0)
+                        .map(([key, count]) => {
+                          const details = result.skipped_details?.[key] || [];
+                          const isExpanded = expandedCategories.has(key);
+                          return (
+                            <div key={key}>
+                              <button
+                                onClick={() => details.length > 0 && toggleCategory(key)}
+                                className={`w-full flex items-center justify-between text-sm p-2 rounded bg-bambu-dark ${
+                                  details.length > 0 ? 'hover:bg-bambu-dark-tertiary cursor-pointer' : ''
+                                }`}
+                              >
+                                <span className="text-white flex items-center gap-2">
+                                  {CATEGORY_LABELS[key] || key}
+                                  {details.length > 0 && (
+                                    isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />
+                                  )}
+                                </span>
+                                <span className="text-yellow-500 font-medium">{count}</span>
+                              </button>
+                              {isExpanded && details.length > 0 && (
+                                <div className="mt-1 ml-4 p-2 rounded bg-bambu-dark-tertiary text-xs text-bambu-gray space-y-1">
+                                  {details.slice(0, 10).map((item, i) => (
+                                    <div key={i}>{item}</div>
+                                  ))}
+                                  {details.length > 10 && (
+                                    <div className="text-bambu-gray/60">...and {details.length - 10} more</div>
+                                  )}
+                                </div>
+                              )}
+                            </div>
+                          );
+                        })}
+                    </div>
+                  </div>
+                )}
+
+                {totalRestored === 0 && totalSkipped === 0 && (
+                  <div className="p-4 text-center text-bambu-gray">
+                    No data was found to restore in the backup file.
+                  </div>
+                )}
+              </div>
+
+              {/* Footer */}
+              <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
+                <Button onClick={onClose}>
+                  Close
+                </Button>
+              </div>
+            </>
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 33 - 43
frontend/src/pages/SettingsPage.tsx

@@ -13,6 +13,7 @@ import { NotificationTemplateEditor } from '../components/NotificationTemplateEd
 import { NotificationLogViewer } from '../components/NotificationLogViewer';
 import { NotificationLogViewer } from '../components/NotificationLogViewer';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { BackupModal } from '../components/BackupModal';
 import { BackupModal } from '../components/BackupModal';
+import { RestoreModal } from '../components/RestoreModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
 import { availableLanguages } from '../i18n';
@@ -31,7 +32,6 @@ export function SettingsPage() {
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
-  const fileInputRef = useRef<HTMLInputElement>(null);
   const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
   const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
 
 
   // Confirm modal states
   // Confirm modal states
@@ -39,6 +39,7 @@ export function SettingsPage() {
   const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
   const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
   const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
   const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
   const [showBackupModal, setShowBackupModal] = useState(false);
   const [showBackupModal, setShowBackupModal] = useState(false);
+  const [showRestoreModal, setShowRestoreModal] = useState(false);
 
 
   const handleDefaultViewChange = (path: string) => {
   const handleDefaultViewChange = (path: string) => {
     setDefaultViewState(path);
     setDefaultViewState(path);
@@ -884,43 +885,19 @@ export function SettingsPage() {
               </div>
               </div>
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <div>
-                  <p className="text-white">Restore Settings</p>
+                  <p className="text-white">Restore Backup</p>
                   <p className="text-sm text-bambu-gray">
                   <p className="text-sm text-bambu-gray">
-                    Import settings from a backup file
+                    Import settings from a backup file with duplicate handling options
                   </p>
                   </p>
                 </div>
                 </div>
-                <div>
-                  <input
-                    ref={fileInputRef}
-                    type="file"
-                    accept=".json,.zip"
-                    className="hidden"
-                    onChange={async (e) => {
-                      const file = e.target.files?.[0];
-                      if (!file) return;
-                      try {
-                        const result = await api.importBackup(file);
-                        if (result.success) {
-                          showToast(result.message, 'success');
-                          queryClient.invalidateQueries();
-                        } else {
-                          showToast(result.message, 'error');
-                        }
-                      } catch (err) {
-                        showToast('Failed to restore backup', 'error');
-                      }
-                      e.target.value = '';
-                    }}
-                  />
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => fileInputRef.current?.click()}
-                  >
-                    <Upload className="w-4 h-4" />
-                    Import
-                  </Button>
-                </div>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => setShowRestoreModal(true)}
+                >
+                  <Upload className="w-4 h-4" />
+                  Restore
+                </Button>
               </div>
               </div>
 
 
               <div className="border-t border-bambu-dark-tertiary pt-4">
               <div className="border-t border-bambu-dark-tertiary pt-4">
@@ -943,9 +920,9 @@ export function SettingsPage() {
               </div>
               </div>
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <div>
-                  <p className="text-white">Clear Local Storage</p>
+                  <p className="text-white">Reset UI Preferences</p>
                   <p className="text-sm text-bambu-gray">
                   <p className="text-sm text-bambu-gray">
-                    Clear browser cache (sidebar order, preferences)
+                    Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.
                   </p>
                   </p>
                 </div>
                 </div>
                 <Button
                 <Button
@@ -954,7 +931,7 @@ export function SettingsPage() {
                   onClick={() => setShowClearStorageConfirm(true)}
                   onClick={() => setShowClearStorageConfirm(true)}
                 >
                 >
                   <Trash2 className="w-4 h-4" />
                   <Trash2 className="w-4 h-4" />
-                  Clear
+                  Reset
                 </Button>
                 </Button>
               </div>
               </div>
             </CardContent>
             </CardContent>
@@ -1423,14 +1400,14 @@ export function SettingsPage() {
       {/* Confirm Modal: Clear Local Storage */}
       {/* Confirm Modal: Clear Local Storage */}
       {showClearStorageConfirm && (
       {showClearStorageConfirm && (
         <ConfirmModal
         <ConfirmModal
-          title="Clear All Local Storage"
-          message="WARNING: This will clear ALL browser data for Bambuddy including your sidebar order, preferences, and cached data. The page will reload after clearing. This action cannot be undone!"
-          confirmText="Clear Everything"
-          variant="danger"
+          title="Reset UI Preferences"
+          message="This will reset all UI preferences to defaults: sidebar order, theme, dashboard layout, view modes, and sorting preferences. Your printers, archives, and server settings will NOT be affected. The page will reload after clearing."
+          confirmText="Reset Preferences"
+          variant="default"
           onConfirm={() => {
           onConfirm={() => {
             setShowClearStorageConfirm(false);
             setShowClearStorageConfirm(false);
             localStorage.clear();
             localStorage.clear();
-            showToast('Local storage cleared. Refreshing...', 'success');
+            showToast('UI preferences reset. Refreshing...', 'success');
             setTimeout(() => window.location.reload(), 1000);
             setTimeout(() => window.location.reload(), 1000);
           }}
           }}
           onCancel={() => setShowClearStorageConfirm(false)}
           onCancel={() => setShowClearStorageConfirm(false)}
@@ -1474,6 +1451,19 @@ export function SettingsPage() {
           }}
           }}
         />
         />
       )}
       )}
+
+      {/* Restore Modal */}
+      {showRestoreModal && (
+        <RestoreModal
+          onClose={() => setShowRestoreModal(false)}
+          onRestore={async (file, overwrite) => {
+            return await api.importBackup(file, overwrite);
+          }}
+          onSuccess={() => {
+            queryClient.invalidateQueries();
+          }}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-79rMOukP.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BEeIB2d_.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Dyo2PpHq.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="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.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" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-C1C_HwA0.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-79rMOukP.css">
+    <script type="module" crossorigin src="/assets/index-Dyo2PpHq.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BEeIB2d_.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов