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

* Added user options to backup module; Fixed HTTP 500 bug

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

+ 486 - 60
backend/app/api/routes/settings.py

@@ -1,15 +1,25 @@
+import io
 import json
+import zipfile
 from datetime import datetime
+from pathlib import Path
+from typing import Optional
 
-from fastapi import APIRouter, Depends, UploadFile, File
-from fastapi.responses import JSONResponse
+from fastapi import APIRouter, Depends, UploadFile, File, Query
+from fastapi.responses import JSONResponse, StreamingResponse
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 
+from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.models.settings import Settings
 from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.printer import Printer
+from backend.app.models.filament import Filament
+from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
+from backend.app.models.archive import PrintArchive
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 
 
@@ -149,62 +159,267 @@ async def update_spoolman_settings(
 
 
 @router.get("/backup")
-async def export_backup(db: AsyncSession = Depends(get_db)):
-    """Export all settings, notification providers, and smart plugs as JSON backup."""
-    # Get all settings
-    result = await db.execute(select(Settings))
-    db_settings = result.scalars().all()
-    settings_data = {s.key: s.value for s in db_settings}
-
-    # Get notification providers
-    result = await db.execute(select(NotificationProvider))
-    providers = result.scalars().all()
-    providers_data = []
-    for p in providers:
-        providers_data.append({
-            "name": p.name,
-            "provider_type": p.provider_type,
-            "enabled": p.enabled,
-            "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
-            "on_print_start": p.on_print_start,
-            "on_print_complete": p.on_print_complete,
-            "on_print_failed": p.on_print_failed,
-            "on_print_stopped": p.on_print_stopped,
-            "on_print_progress": p.on_print_progress,
-            "on_printer_offline": p.on_printer_offline,
-            "on_printer_error": p.on_printer_error,
-            "on_filament_low": p.on_filament_low,
-            "on_maintenance_due": p.on_maintenance_due,
-            "quiet_hours_enabled": p.quiet_hours_enabled,
-            "quiet_hours_start": p.quiet_hours_start,
-            "quiet_hours_end": p.quiet_hours_end,
-        })
-
-    # Get smart plugs
-    result = await db.execute(select(SmartPlug))
-    plugs = result.scalars().all()
-    plugs_data = []
-    for plug in plugs:
-        plugs_data.append({
-            "name": plug.name,
-            "ip_address": plug.ip_address,
-            "enabled": plug.enabled,
-            "auto_off_enabled": plug.auto_off_enabled,
-            "auto_off_delay_minutes": plug.auto_off_delay_minutes,
-        })
-
-    backup = {
-        "version": "1.0",
+async def export_backup(
+    db: AsyncSession = Depends(get_db),
+    include_settings: bool = Query(True, description="Include app settings"),
+    include_notifications: bool = Query(True, description="Include notification providers"),
+    include_templates: bool = Query(True, description="Include notification templates"),
+    include_smart_plugs: bool = Query(True, description="Include smart plugs"),
+    include_printers: bool = Query(False, description="Include printers (without access codes)"),
+    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"),
+):
+    """Export selected data as JSON backup."""
+    backup: dict = {
+        "version": "2.0",
         "exported_at": datetime.utcnow().isoformat(),
-        "settings": settings_data,
-        "notification_providers": providers_data,
-        "smart_plugs": plugs_data,
+        "included": [],
     }
 
+    # Settings
+    if include_settings:
+        result = await db.execute(select(Settings))
+        db_settings = result.scalars().all()
+        backup["settings"] = {s.key: s.value for s in db_settings}
+        backup["included"].append("settings")
+
+    # Notification providers
+    if include_notifications:
+        result = await db.execute(select(NotificationProvider))
+        providers = result.scalars().all()
+        backup["notification_providers"] = []
+        for p in providers:
+            backup["notification_providers"].append({
+                "name": p.name,
+                "provider_type": p.provider_type,
+                "enabled": p.enabled,
+                "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
+                "on_print_start": p.on_print_start,
+                "on_print_complete": p.on_print_complete,
+                "on_print_failed": p.on_print_failed,
+                "on_print_stopped": p.on_print_stopped,
+                "on_print_progress": p.on_print_progress,
+                "on_printer_offline": p.on_printer_offline,
+                "on_printer_error": p.on_printer_error,
+                "on_filament_low": p.on_filament_low,
+                "on_maintenance_due": p.on_maintenance_due,
+                "quiet_hours_enabled": p.quiet_hours_enabled,
+                "quiet_hours_start": p.quiet_hours_start,
+                "quiet_hours_end": p.quiet_hours_end,
+                "daily_digest_enabled": getattr(p, 'daily_digest_enabled', False),
+                "daily_digest_time": getattr(p, 'daily_digest_time', None),
+                "printer_id": getattr(p, 'printer_id', None),
+            })
+        backup["included"].append("notification_providers")
+
+    # Notification templates
+    if include_templates:
+        result = await db.execute(select(NotificationTemplate))
+        templates = result.scalars().all()
+        backup["notification_templates"] = []
+        for t in templates:
+            backup["notification_templates"].append({
+                "event_type": t.event_type,
+                "name": t.name,
+                "title_template": t.title_template,
+                "body_template": t.body_template,
+                "is_default": t.is_default,
+            })
+        backup["included"].append("notification_templates")
+
+    # Smart plugs
+    if include_smart_plugs:
+        result = await db.execute(select(SmartPlug))
+        plugs = result.scalars().all()
+        backup["smart_plugs"] = []
+        for plug in plugs:
+            backup["smart_plugs"].append({
+                "name": plug.name,
+                "ip_address": plug.ip_address,
+                "printer_id": plug.printer_id,
+                "enabled": plug.enabled,
+                "auto_on": plug.auto_on,
+                "auto_off": plug.auto_off,
+                "off_delay_mode": plug.off_delay_mode,
+                "off_delay_minutes": plug.off_delay_minutes,
+                "off_temp_threshold": plug.off_temp_threshold,
+                "username": plug.username,
+                "password": plug.password,
+                "power_alert_enabled": plug.power_alert_enabled,
+                "power_alert_high": plug.power_alert_high,
+                "power_alert_low": plug.power_alert_low,
+                "schedule_enabled": plug.schedule_enabled,
+                "schedule_on_time": plug.schedule_on_time,
+                "schedule_off_time": plug.schedule_off_time,
+            })
+        backup["included"].append("smart_plugs")
+
+    # Printers (without access codes for security)
+    if include_printers:
+        result = await db.execute(select(Printer))
+        printers = result.scalars().all()
+        backup["printers"] = []
+        for printer in printers:
+            backup["printers"].append({
+                "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,
+            })
+        backup["included"].append("printers")
+
+    # Filaments
+    if include_filaments:
+        result = await db.execute(select(Filament))
+        filaments = result.scalars().all()
+        backup["filaments"] = []
+        for f in filaments:
+            backup["filaments"].append({
+                "name": f.name,
+                "type": f.type,
+                "brand": f.brand,
+                "color": f.color,
+                "color_hex": f.color_hex,
+                "cost_per_kg": f.cost_per_kg,
+                "spool_weight_g": f.spool_weight_g,
+                "currency": f.currency,
+                "density": f.density,
+                "print_temp_min": f.print_temp_min,
+                "print_temp_max": f.print_temp_max,
+                "bed_temp_min": f.bed_temp_min,
+                "bed_temp_max": f.bed_temp_max,
+            })
+        backup["included"].append("filaments")
+
+    # Maintenance types and records
+    if include_maintenance:
+        # Maintenance types
+        result = await db.execute(select(MaintenanceType))
+        types = result.scalars().all()
+        backup["maintenance_types"] = []
+        for mt in types:
+            backup["maintenance_types"].append({
+                "name": mt.name,
+                "description": mt.description,
+                "default_interval_hours": mt.default_interval_hours,
+                "interval_type": mt.interval_type,
+                "icon": mt.icon,
+                "is_system": mt.is_system,
+            })
+        backup["included"].append("maintenance_types")
+
+    # Print archives with file paths for ZIP
+    archive_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
+    if include_archives:
+        result = await db.execute(select(PrintArchive))
+        archives = result.scalars().all()
+        backup["archives"] = []
+        base_dir = app_settings.base_dir
+
+        for a in archives:
+            archive_data = {
+                "filename": a.filename,
+                "file_size": a.file_size,
+                "content_hash": a.content_hash,
+                "print_name": a.print_name,
+                "print_time_seconds": a.print_time_seconds,
+                "filament_used_grams": a.filament_used_grams,
+                "filament_type": a.filament_type,
+                "filament_color": a.filament_color,
+                "layer_height": a.layer_height,
+                "total_layers": a.total_layers,
+                "nozzle_diameter": a.nozzle_diameter,
+                "bed_temperature": a.bed_temperature,
+                "nozzle_temperature": a.nozzle_temperature,
+                "status": a.status,
+                "started_at": a.started_at.isoformat() if a.started_at else None,
+                "completed_at": a.completed_at.isoformat() if a.completed_at else None,
+                "makerworld_url": a.makerworld_url,
+                "designer": a.designer,
+                "is_favorite": a.is_favorite,
+                "tags": a.tags,
+                "notes": a.notes,
+                "cost": a.cost,
+                "failure_reason": a.failure_reason,
+                "energy_kwh": a.energy_kwh,
+                "energy_cost": a.energy_cost,
+                "extra_data": a.extra_data,
+                "photos": a.photos,
+            }
+
+            # Collect file paths for ZIP
+            if a.file_path:
+                file_path = base_dir / a.file_path
+                if file_path.exists():
+                    archive_data["file_path"] = a.file_path
+                    archive_files.append((a.file_path, file_path))
+
+            if a.thumbnail_path:
+                thumb_path = base_dir / a.thumbnail_path
+                if thumb_path.exists():
+                    archive_data["thumbnail_path"] = a.thumbnail_path
+                    archive_files.append((a.thumbnail_path, thumb_path))
+
+            if a.timelapse_path:
+                timelapse_path = base_dir / a.timelapse_path
+                if timelapse_path.exists():
+                    archive_data["timelapse_path"] = a.timelapse_path
+                    archive_files.append((a.timelapse_path, timelapse_path))
+
+            if a.source_3mf_path:
+                source_path = base_dir / a.source_3mf_path
+                if source_path.exists():
+                    archive_data["source_3mf_path"] = a.source_3mf_path
+                    archive_files.append((a.source_3mf_path, source_path))
+
+            # Include photos
+            if a.photos:
+                for photo in a.photos:
+                    photo_path = base_dir / "archive" / "photos" / photo
+                    if photo_path.exists():
+                        zip_photo_path = f"archive/photos/{photo}"
+                        archive_files.append((zip_photo_path, photo_path))
+
+            backup["archives"].append(archive_data)
+        backup["included"].append("archives")
+
+    # If archives included, create ZIP file with all files
+    if include_archives and archive_files:
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
+            # Add backup.json
+            zf.writestr("backup.json", json.dumps(backup, indent=2))
+
+            # Add all archive files
+            added_files = set()
+            for zip_path, local_path in archive_files:
+                if zip_path not in added_files and local_path.exists():
+                    try:
+                        zf.write(local_path, zip_path)
+                        added_files.add(zip_path)
+                    except Exception:
+                        pass  # Skip files that can't be read
+
+        zip_buffer.seek(0)
+        filename = f"bambusy-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}"}
+        )
+
+    # Otherwise return JSON
     return JSONResponse(
         content=backup,
         headers={
-            "Content-Disposition": f"attachment; filename=bambutrack-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
+            "Content-Disposition": f"attachment; filename=bambusy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
         }
     )
 
@@ -214,14 +429,54 @@ async def import_backup(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
 ):
-    """Restore settings, notification providers, and smart plugs from JSON backup."""
+    """Restore data from JSON or ZIP backup. Skips duplicates."""
     try:
         content = await file.read()
-        backup = json.loads(content.decode("utf-8"))
+        base_dir = app_settings.base_dir
+        files_restored = 0
+
+        # Check if it's a ZIP file
+        if file.filename and file.filename.endswith('.zip'):
+            try:
+                zip_buffer = io.BytesIO(content)
+                with zipfile.ZipFile(zip_buffer, 'r') as zf:
+                    # Extract backup.json
+                    if 'backup.json' not in zf.namelist():
+                        return {"success": False, "message": "Invalid ZIP: missing backup.json"}
+
+                    backup_content = zf.read('backup.json')
+                    backup = json.loads(backup_content.decode("utf-8"))
+
+                    # Extract all other files to base_dir
+                    for zip_path in zf.namelist():
+                        if zip_path == 'backup.json':
+                            continue
+                        # Ensure path is safe (no path traversal)
+                        if '..' in zip_path or zip_path.startswith('/'):
+                            continue
+                        target_path = base_dir / zip_path
+                        target_path.parent.mkdir(parents=True, exist_ok=True)
+                        with zf.open(zip_path) as src, open(target_path, 'wb') as dst:
+                            dst.write(src.read())
+                            files_restored += 1
+            except zipfile.BadZipFile:
+                return {"success": False, "message": "Invalid ZIP file"}
+        else:
+            backup = json.loads(content.decode("utf-8"))
+    except json.JSONDecodeError as e:
+        return {"success": False, "message": f"Invalid JSON: {str(e)}"}
     except Exception as e:
         return {"success": False, "message": f"Invalid backup file: {str(e)}"}
 
-    restored = {"settings": 0, "notification_providers": 0, "smart_plugs": 0}
+    restored = {
+        "settings": 0,
+        "notification_providers": 0,
+        "notification_templates": 0,
+        "smart_plugs": 0,
+        "printers": 0,
+        "filaments": 0,
+        "maintenance_types": 0,
+    }
 
     # Restore settings
     if "settings" in backup:
@@ -232,7 +487,6 @@ async def import_backup(
     # Restore notification providers (skip duplicates by name)
     if "notification_providers" in backup:
         for provider_data in backup["notification_providers"]:
-            # Check if provider with same name exists
             result = await db.execute(
                 select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
             )
@@ -255,14 +509,42 @@ async def import_backup(
                     quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
+                    daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
+                    daily_digest_time=provider_data.get("daily_digest_time"),
+                    printer_id=provider_data.get("printer_id"),
                 )
                 db.add(provider)
                 restored["notification_providers"] += 1
 
+    # Restore notification templates (update existing by event_type)
+    if "notification_templates" in backup:
+        for template_data in backup["notification_templates"]:
+            result = await db.execute(
+                select(NotificationTemplate).where(
+                    NotificationTemplate.event_type == template_data["event_type"]
+                )
+            )
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Update existing template
+                existing.name = template_data.get("name", existing.name)
+                existing.title_template = template_data.get("title_template", existing.title_template)
+                existing.body_template = template_data.get("body_template", existing.body_template)
+                existing.is_default = template_data.get("is_default", False)
+            else:
+                template = NotificationTemplate(
+                    event_type=template_data["event_type"],
+                    name=template_data["name"],
+                    title_template=template_data["title_template"],
+                    body_template=template_data["body_template"],
+                    is_default=template_data.get("is_default", False),
+                )
+                db.add(template)
+            restored["notification_templates"] += 1
+
     # Restore smart plugs (skip duplicates by IP)
     if "smart_plugs" in backup:
         for plug_data in backup["smart_plugs"]:
-            # Check if plug with same IP exists
             result = await db.execute(
                 select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
             )
@@ -271,17 +553,161 @@ async def import_backup(
                 plug = SmartPlug(
                     name=plug_data["name"],
                     ip_address=plug_data["ip_address"],
+                    printer_id=plug_data.get("printer_id"),
                     enabled=plug_data.get("enabled", True),
-                    auto_off_enabled=plug_data.get("auto_off_enabled", False),
-                    auto_off_delay_minutes=plug_data.get("auto_off_delay_minutes", 5),
+                    auto_on=plug_data.get("auto_on", True),
+                    auto_off=plug_data.get("auto_off", True),
+                    off_delay_mode=plug_data.get("off_delay_mode", "time"),
+                    off_delay_minutes=plug_data.get("off_delay_minutes", 5),
+                    off_temp_threshold=plug_data.get("off_temp_threshold", 70),
+                    username=plug_data.get("username"),
+                    password=plug_data.get("password"),
+                    power_alert_enabled=plug_data.get("power_alert_enabled", False),
+                    power_alert_high=plug_data.get("power_alert_high"),
+                    power_alert_low=plug_data.get("power_alert_low"),
+                    schedule_enabled=plug_data.get("schedule_enabled", False),
+                    schedule_on_time=plug_data.get("schedule_on_time"),
+                    schedule_off_time=plug_data.get("schedule_off_time"),
                 )
                 db.add(plug)
                 restored["smart_plugs"] += 1
 
+    # Restore printers (skip duplicates by serial_number, requires access_code to be set manually)
+    if "printers" in backup:
+        for printer_data in backup["printers"]:
+            result = await db.execute(
+                select(Printer).where(Printer.serial_number == printer_data["serial_number"])
+            )
+            existing = result.scalar_one_or_none()
+            if not existing:
+                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
+                    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
+                    auto_archive=printer_data.get("auto_archive", True),
+                    print_hours_offset=printer_data.get("print_hours_offset", 0.0),
+                )
+                db.add(printer)
+                restored["printers"] += 1
+
+    # Restore filaments (skip duplicates by name+type+brand)
+    if "filaments" in backup:
+        for filament_data in backup["filaments"]:
+            result = await db.execute(
+                select(Filament).where(
+                    Filament.name == filament_data["name"],
+                    Filament.type == filament_data["type"],
+                    Filament.brand == filament_data.get("brand"),
+                )
+            )
+            existing = result.scalar_one_or_none()
+            if not existing:
+                filament = Filament(
+                    name=filament_data["name"],
+                    type=filament_data["type"],
+                    brand=filament_data.get("brand"),
+                    color=filament_data.get("color"),
+                    color_hex=filament_data.get("color_hex"),
+                    cost_per_kg=filament_data.get("cost_per_kg", 25.0),
+                    spool_weight_g=filament_data.get("spool_weight_g", 1000.0),
+                    currency=filament_data.get("currency", "USD"),
+                    density=filament_data.get("density"),
+                    print_temp_min=filament_data.get("print_temp_min"),
+                    print_temp_max=filament_data.get("print_temp_max"),
+                    bed_temp_min=filament_data.get("bed_temp_min"),
+                    bed_temp_max=filament_data.get("bed_temp_max"),
+                )
+                db.add(filament)
+                restored["filaments"] += 1
+
+    # Restore maintenance types (skip duplicates by name)
+    if "maintenance_types" in backup:
+        for mt_data in backup["maintenance_types"]:
+            result = await db.execute(
+                select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])
+            )
+            existing = result.scalar_one_or_none()
+            if not existing:
+                mt = MaintenanceType(
+                    name=mt_data["name"],
+                    description=mt_data.get("description"),
+                    default_interval_hours=mt_data.get("default_interval_hours", 100.0),
+                    interval_type=mt_data.get("interval_type", "hours"),
+                    icon=mt_data.get("icon"),
+                    is_system=mt_data.get("is_system", False),
+                )
+                db.add(mt)
+                restored["maintenance_types"] += 1
+
+    # Restore archives (skip duplicates by content_hash)
+    if "archives" in backup:
+        for archive_data in backup["archives"]:
+            # Skip if no content_hash or already exists
+            content_hash = archive_data.get("content_hash")
+            if content_hash:
+                result = await db.execute(
+                    select(PrintArchive).where(PrintArchive.content_hash == content_hash)
+                )
+                existing = result.scalar_one_or_none()
+                if existing:
+                    continue
+
+            # Only restore if file exists (from ZIP extraction)
+            file_path = archive_data.get("file_path")
+            if file_path and (base_dir / file_path).exists():
+                archive = PrintArchive(
+                    filename=archive_data["filename"],
+                    file_path=file_path,
+                    file_size=archive_data.get("file_size", 0),
+                    content_hash=content_hash,
+                    thumbnail_path=archive_data.get("thumbnail_path"),
+                    timelapse_path=archive_data.get("timelapse_path"),
+                    source_3mf_path=archive_data.get("source_3mf_path"),
+                    print_name=archive_data.get("print_name"),
+                    print_time_seconds=archive_data.get("print_time_seconds"),
+                    filament_used_grams=archive_data.get("filament_used_grams"),
+                    filament_type=archive_data.get("filament_type"),
+                    filament_color=archive_data.get("filament_color"),
+                    layer_height=archive_data.get("layer_height"),
+                    total_layers=archive_data.get("total_layers"),
+                    nozzle_diameter=archive_data.get("nozzle_diameter"),
+                    bed_temperature=archive_data.get("bed_temperature"),
+                    nozzle_temperature=archive_data.get("nozzle_temperature"),
+                    status=archive_data.get("status", "completed"),
+                    makerworld_url=archive_data.get("makerworld_url"),
+                    designer=archive_data.get("designer"),
+                    is_favorite=archive_data.get("is_favorite", False),
+                    tags=archive_data.get("tags"),
+                    notes=archive_data.get("notes"),
+                    cost=archive_data.get("cost"),
+                    failure_reason=archive_data.get("failure_reason"),
+                    energy_kwh=archive_data.get("energy_kwh"),
+                    energy_cost=archive_data.get("energy_cost"),
+                    extra_data=archive_data.get("extra_data"),
+                    photos=archive_data.get("photos"),
+                )
+                db.add(archive)
+                restored["archives"] = restored.get("archives", 0) + 1
+
     await db.commit()
 
+    # Build summary message
+    parts = []
+    for key, count in restored.items():
+        if count > 0:
+            parts.append(f"{count} {key.replace('_', ' ')}")
+
+    if files_restored > 0:
+        parts.append(f"{files_restored} files")
+
     return {
         "success": True,
-        "message": f"Restored {restored['settings']} settings, {restored['notification_providers']} notification providers, {restored['smart_plugs']} smart plugs",
+        "message": f"Restored: {', '.join(parts)}" if parts else "Nothing to restore",
         "restored": restored,
+        "files_restored": files_restored,
     }

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

@@ -1149,9 +1149,31 @@ export const api = {
     }),
   resetSettings: () =>
     request<AppSettings>('/settings/reset', { method: 'POST' }),
-  exportBackup: async () => {
-    const response = await fetch(`${API_BASE}/settings/backup`);
-    return response.json();
+  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.printers !== undefined) params.set('include_printers', String(categories.printers));
+      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));
+    }
+    const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
+    const response = await fetch(url);
+
+    // Get filename from Content-Disposition header
+    const contentDisposition = response.headers.get('Content-Disposition');
+    let filename = 'bambusy-backup.json';
+    if (contentDisposition) {
+      const match = contentDisposition.match(/filename=([^;]+)/);
+      if (match) filename = match[1].trim();
+    }
+
+    const blob = await response.blob();
+    return { blob, filename };
   },
   importBackup: async (file: File) => {
     const formData = new FormData();

+ 267 - 0
frontend/src/components/BackupModal.tsx

@@ -0,0 +1,267 @@
+import { useEffect, useState } from 'react';
+import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+
+interface BackupCategory {
+  id: string;
+  labelKey: string;
+  defaultLabel: string;
+  icon: React.ReactNode;
+  default: boolean;
+  description: string;
+}
+
+const BACKUP_CATEGORIES: BackupCategory[] = [
+  {
+    id: 'settings',
+    labelKey: 'backup.categories.settings',
+    defaultLabel: 'App Settings',
+    icon: <Settings className="w-4 h-4" />,
+    default: true,
+    description: 'Language, theme, update preferences',
+  },
+  {
+    id: 'notifications',
+    labelKey: 'backup.categories.notifications',
+    defaultLabel: 'Notification Providers',
+    icon: <Bell className="w-4 h-4" />,
+    default: true,
+    description: 'ntfy, Pushover, Discord, etc.',
+  },
+  {
+    id: 'templates',
+    labelKey: 'backup.categories.templates',
+    defaultLabel: 'Notification Templates',
+    icon: <FileText className="w-4 h-4" />,
+    default: true,
+    description: 'Custom message templates',
+  },
+  {
+    id: 'smart_plugs',
+    labelKey: 'backup.categories.smartPlugs',
+    defaultLabel: 'Smart Plugs',
+    icon: <Plug className="w-4 h-4" />,
+    default: true,
+    description: 'Tasmota plug configurations',
+  },
+  {
+    id: 'printers',
+    labelKey: 'backup.categories.printers',
+    defaultLabel: 'Printers',
+    icon: <Printer className="w-4 h-4" />,
+    default: false,
+    description: 'Printer info (access codes excluded)',
+  },
+  {
+    id: 'filaments',
+    labelKey: 'backup.categories.filaments',
+    defaultLabel: 'Filament Inventory',
+    icon: <Palette className="w-4 h-4" />,
+    default: false,
+    description: 'Filament types and costs',
+  },
+  {
+    id: 'maintenance',
+    labelKey: 'backup.categories.maintenance',
+    defaultLabel: 'Maintenance Types',
+    icon: <Wrench className="w-4 h-4" />,
+    default: false,
+    description: 'Custom maintenance schedules',
+  },
+  {
+    id: 'archives',
+    labelKey: 'backup.categories.archives',
+    defaultLabel: 'Print Archives',
+    icon: <Archive className="w-4 h-4" />,
+    default: false,
+    description: 'All print data + files (3MF, thumbnails, photos)',
+  },
+];
+
+interface BackupModalProps {
+  onClose: () => void;
+  onExport: (categories: Record<string, boolean>) => Promise<void>;
+}
+
+export function BackupModal({ onClose, onExport }: BackupModalProps) {
+  const { t } = useTranslation();
+  const [selected, setSelected] = useState<Record<string, boolean>>(() => {
+    const initial: Record<string, boolean> = {};
+    BACKUP_CATEGORIES.forEach((cat) => {
+      initial[cat.id] = cat.default;
+    });
+    return initial;
+  });
+  const [isExporting, setIsExporting] = useState(false);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const toggleCategory = (id: string) => {
+    setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
+  };
+
+  const selectAll = () => {
+    const all: Record<string, boolean> = {};
+    BACKUP_CATEGORIES.forEach((cat) => {
+      all[cat.id] = true;
+    });
+    setSelected(all);
+  };
+
+  const selectNone = () => {
+    const none: Record<string, boolean> = {};
+    BACKUP_CATEGORIES.forEach((cat) => {
+      none[cat.id] = false;
+    });
+    setSelected(none);
+  };
+
+  const selectedCount = Object.values(selected).filter(Boolean).length;
+
+  const handleExport = async () => {
+    setIsExporting(true);
+    try {
+      await onExport(selected);
+    } finally {
+      setIsExporting(false);
+    }
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={isExporting ? undefined : onClose}
+    >
+      <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 bg-bambu-green/20 text-bambu-green">
+                <Download className="w-5 h-5" />
+              </div>
+              <div>
+                <h3 className="text-lg font-semibold text-white">
+                  {t('backup.exportTitle', { defaultValue: 'Export Backup' })}
+                </h3>
+                <p className="text-sm text-bambu-gray">
+                  {t('backup.selectCategories', { defaultValue: 'Select data to include' })}
+                </p>
+              </div>
+            </div>
+            <button
+              onClick={onClose}
+              className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Quick actions */}
+          <div className="flex gap-2 px-4 pt-4">
+            <button
+              onClick={selectAll}
+              disabled={isExporting}
+              className="text-sm text-bambu-green hover:text-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              {t('common.selectAll', { defaultValue: 'Select All' })}
+            </button>
+            <span className="text-bambu-gray">|</span>
+            <button
+              onClick={selectNone}
+              disabled={isExporting}
+              className="text-sm text-bambu-gray hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              {t('common.selectNone', { defaultValue: 'Select None' })}
+            </button>
+          </div>
+
+          {/* Categories */}
+          <div className={`p-4 space-y-2 max-h-[400px] overflow-y-auto ${isExporting ? 'opacity-50 pointer-events-none' : ''}`}>
+            {BACKUP_CATEGORIES.map((category) => (
+              <label
+                key={category.id}
+                className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
+                  selected[category.id]
+                    ? 'bg-bambu-green/10 border border-bambu-green/30'
+                    : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
+                }`}
+              >
+                <input
+                  type="checkbox"
+                  checked={selected[category.id]}
+                  onChange={() => toggleCategory(category.id)}
+                  disabled={isExporting}
+                  className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
+                />
+                <div className={`${selected[category.id] ? 'text-bambu-green' : 'text-bambu-gray'}`}>
+                  {category.icon}
+                </div>
+                <div className="flex-1">
+                  <div className="text-white text-sm font-medium">
+                    {t(category.labelKey, { defaultValue: category.defaultLabel })}
+                  </div>
+                  <div className="text-xs text-bambu-gray">{category.description}</div>
+                </div>
+              </label>
+            ))}
+          </div>
+
+          {/* Archive warning */}
+          {selected.archives && (
+            <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">
+                  <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>
+                </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">
+              {t('backup.selectedCount', {
+                count: selectedCount,
+                defaultValue: `${selectedCount} categories selected`,
+              })}
+            </span>
+            <div className="flex gap-3">
+              <Button variant="secondary" onClick={onClose} disabled={isExporting}>
+                {t('common.cancel', { defaultValue: 'Cancel' })}
+              </Button>
+              <Button
+                onClick={handleExport}
+                disabled={selectedCount === 0 || isExporting}
+                className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
+              >
+                {isExporting ? (
+                  <>
+                    <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                    {t('backup.exporting', { defaultValue: 'Exporting...' })}
+                  </>
+                ) : (
+                  <>
+                    <Download className="w-4 h-4 mr-2" />
+                    {t('backup.export', { defaultValue: 'Export' })}
+                  </>
+                )}
+              </Button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 28 - 18
frontend/src/pages/SettingsPage.tsx

@@ -12,6 +12,7 @@ import { AddNotificationModal } from '../components/AddNotificationModal';
 import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
 import { NotificationLogViewer } from '../components/NotificationLogViewer';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { BackupModal } from '../components/BackupModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
@@ -37,6 +38,7 @@ export function SettingsPage() {
   const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
   const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
   const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
+  const [showBackupModal, setShowBackupModal] = useState(false);
 
   const handleDefaultViewChange = (path: string) => {
     setDefaultViewState(path);
@@ -866,29 +868,15 @@ export function SettingsPage() {
               {/* Backup/Restore */}
               <div className="flex items-center justify-between">
                 <div>
-                  <p className="text-white">Backup Settings</p>
+                  <p className="text-white">Backup Data</p>
                   <p className="text-sm text-bambu-gray">
-                    Export settings, providers, and plugs to JSON
+                    Export settings, providers, printers, and more
                   </p>
                 </div>
                 <Button
                   variant="secondary"
                   size="sm"
-                  onClick={async () => {
-                    try {
-                      const backup = await api.exportBackup();
-                      const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
-                      const url = URL.createObjectURL(blob);
-                      const a = document.createElement('a');
-                      a.href = url;
-                      a.download = `bambutrack-backup-${new Date().toISOString().slice(0, 10)}.json`;
-                      a.click();
-                      URL.revokeObjectURL(url);
-                      showToast('Backup downloaded', 'success');
-                    } catch (err) {
-                      showToast('Failed to create backup', 'error');
-                    }
-                  }}
+                  onClick={() => setShowBackupModal(true)}
                 >
                   <Download className="w-4 h-4" />
                   Export
@@ -905,7 +893,7 @@ export function SettingsPage() {
                   <input
                     ref={fileInputRef}
                     type="file"
-                    accept=".json"
+                    accept=".json,.zip"
                     className="hidden"
                     onChange={async (e) => {
                       const file = e.target.files?.[0];
@@ -1464,6 +1452,28 @@ export function SettingsPage() {
           onCancel={() => setShowBulkPlugConfirm(null)}
         />
       )}
+
+      {/* Backup Modal */}
+      {showBackupModal && (
+        <BackupModal
+          onClose={() => setShowBackupModal(false)}
+          onExport={async (categories) => {
+            setShowBackupModal(false);
+            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', 'success');
+            } catch (err) {
+              showToast('Failed to create backup', 'error');
+            }
+          }}
+        />
+      )}
     </div>
   );
 }

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


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


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DRtbW6PZ.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-BdUzePl9.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Crbfjp9b.css">
+    <script type="module" crossorigin src="/assets/index-DRtbW6PZ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-79rMOukP.css">
   </head>
   <body>
     <div id="root"></div>

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