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

This commit is contained in:
maziggy
2025-12-09 08:20:27 +00:00
parent 838b613359
commit a0f0892ea0
8 changed files with 1213 additions and 488 deletions
+486 -60
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>