mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 06:31:22 +02:00
* Added user options to backup module; Fixed HTTP 500 bug
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user