- Improved backup/restore module

This commit is contained in:
maziggy
2025-12-09 10:55:54 +00:00
parent b024880cb6
commit 3f9b7e0b7f
9 changed files with 170 additions and 96 deletions
+48 -7
View File
@@ -22,6 +22,7 @@ from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance,
from backend.app.models.archive import PrintArchive
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
from backend.app.services.printer_manager import printer_manager
from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -170,6 +171,7 @@ async def export_backup(
include_filaments: bool = Query(False, description="Include filament inventory"),
include_maintenance: bool = Query(False, description="Include maintenance types and records"),
include_archives: bool = Query(False, description="Include print archive metadata"),
include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
):
"""Export selected data as JSON backup."""
backup: dict = {
@@ -256,25 +258,29 @@ async def export_backup(
})
backup["included"].append("smart_plugs")
# Printers (without access codes for security)
# Printers (access codes only included if explicitly requested)
if include_printers:
result = await db.execute(select(Printer))
printers = result.scalars().all()
backup["printers"] = []
for printer in printers:
backup["printers"].append({
printer_data = {
"name": printer.name,
"serial_number": printer.serial_number,
"ip_address": printer.ip_address,
# access_code intentionally excluded for security
"model": printer.model,
"location": printer.location,
"nozzle_count": printer.nozzle_count,
"is_active": printer.is_active,
"auto_archive": printer.auto_archive,
"print_hours_offset": printer.print_hours_offset,
})
}
if include_access_codes:
printer_data["access_code"] = printer.access_code
backup["printers"].append(printer_data)
backup["included"].append("printers")
if include_access_codes:
backup["included"].append("access_codes")
# Filaments
if include_filaments:
@@ -501,7 +507,14 @@ async def import_backup(
# Restore settings (always overwrites)
if "settings" in backup:
for key, value in backup["settings"].items():
await set_setting(db, key, value)
# Convert value to proper string format for storage
if isinstance(value, bool):
str_value = "true" if value else "false"
elif value is None:
str_value = "None"
else:
str_value = str(value)
await set_setting(db, key, str_value)
restored["settings"] += 1
# Restore notification providers (skip or overwrite duplicates by name)
@@ -662,15 +675,27 @@ async def import_backup(
skipped["printers"] += 1
skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
else:
# Use access code from backup if provided, otherwise require manual setup
access_code = printer_data.get("access_code")
has_access_code = access_code and access_code != "CHANGE_ME"
is_active_from_backup = printer_data.get("is_active", False)
# Handle bool or string "true"/"false"
if isinstance(is_active_from_backup, str):
is_active_from_backup = is_active_from_backup.lower() == "true"
import logging
logger = logging.getLogger(__name__)
logger.info(f"Restore: Creating printer {printer_data['name']}, has_access_code={has_access_code}, is_active={is_active_from_backup if has_access_code else False}")
printer = Printer(
name=printer_data["name"],
serial_number=printer_data["serial_number"],
ip_address=printer_data["ip_address"],
access_code="CHANGE_ME", # Must be set manually for security
access_code=access_code if has_access_code else "CHANGE_ME",
model=printer_data.get("model"),
location=printer_data.get("location"),
nozzle_count=printer_data.get("nozzle_count", 1),
is_active=False, # Disabled until access_code is set
is_active=is_active_from_backup if has_access_code else False,
auto_archive=printer_data.get("auto_archive", True),
print_hours_offset=printer_data.get("print_hours_offset", 0.0),
)
@@ -815,10 +840,26 @@ async def import_backup(
select(Printer).where(Printer.is_active == True)
)
active_printers = result.scalars().all()
import logging
logger = logging.getLogger(__name__)
logger.info(f"Restore: Found {len(active_printers)} active printers to reconnect")
for printer in active_printers:
logger.info(f"Restore: Reconnecting printer {printer.name} (id={printer.id}, ip={printer.ip_address})")
# This will disconnect existing connection (if any) and reconnect
await printer_manager.connect_printer(printer)
# If settings were restored, check if Spoolman needs to be reconnected
if "settings" in backup:
spoolman_enabled = await get_setting(db, "spoolman_enabled")
spoolman_url = await get_setting(db, "spoolman_url")
if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
try:
client = await init_spoolman_client(spoolman_url)
if await client.health_check():
pass # Connected successfully
except Exception:
pass # Spoolman connection failed, but don't fail the restore
# Build summary message
restored_parts = []
for key, count in restored.items():
+1
View File
@@ -1160,6 +1160,7 @@ export const api = {
if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
}
const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
const response = await fetch(url);
+32 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2 } from 'lucide-react';
import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
interface BackupCategory {
id: string;
@@ -94,6 +95,7 @@ export function BackupModal({ onClose, onExport }: BackupModalProps) {
});
return initial;
});
const [includeAccessCodes, setIncludeAccessCodes] = useState(false);
const [isExporting, setIsExporting] = useState(false);
// Close on Escape key
@@ -130,7 +132,7 @@ export function BackupModal({ onClose, onExport }: BackupModalProps) {
const handleExport = async () => {
setIsExporting(true);
try {
await onExport(selected);
await onExport({ ...selected, access_codes: includeAccessCodes && selected.printers });
} finally {
setIsExporting(false);
}
@@ -221,14 +223,40 @@ export function BackupModal({ onClose, onExport }: BackupModalProps) {
<div className="mx-4 mb-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<Archive className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="text-yellow-200">
<div className="text-yellow-200 dark:text-yellow-200 text-yellow-700">
<span className="font-medium">ZIP file will be created.</span>
<span className="text-yellow-200/70"> Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.</span>
<span className="text-yellow-600 dark:text-yellow-200/70"> Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.</span>
</div>
</div>
</div>
)}
{/* Access codes option - only shown when printers are selected */}
{selected.printers && (
<div className="mx-4 mb-2 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
<div className="flex items-center justify-between">
<div className="flex items-start gap-2">
<Key className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-white">Include Access Codes</p>
<p className="text-xs text-bambu-gray">For transferring to another machine</p>
</div>
</div>
<Toggle checked={includeAccessCodes} onChange={setIncludeAccessCodes} />
</div>
{includeAccessCodes && (
<div className="mt-2 p-2 rounded bg-orange-500/10 border border-orange-500/30">
<div className="flex items-start gap-2 text-xs">
<AlertTriangle className="w-3 h-3 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
<span className="text-orange-700 dark:text-orange-200">
Access codes will be included in plain text. Keep this backup file secure!
</span>
</div>
</div>
)}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
<span className="text-sm text-bambu-gray">
+10 -3
View File
@@ -96,9 +96,14 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={state !== 'restoring' ? onClose : undefined}
onMouseDown={(e) => {
// Only close if clicking directly on the backdrop, not on children
if (e.target === e.currentTarget && state !== 'restoring') {
onClose();
}
}}
>
<Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Card className="w-full max-w-lg">
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
@@ -155,6 +160,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
onChange={handleFileSelect}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
selectedFile
@@ -230,10 +236,11 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose}>
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
type="button"
onClick={handleRestore}
disabled={!selectedFile}
className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50"
+21 -24
View File
@@ -37,9 +37,9 @@ export function SpoolmanSettings() {
const [localEnabled, setLocalEnabled] = useState(false);
const [localUrl, setLocalUrl] = useState('');
const [localSyncMode, setLocalSyncMode] = useState('auto');
const [hasChanges, setHasChanges] = useState(false);
const [showSaved, setShowSaved] = useState(false);
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
const [isInitialized, setIsInitialized] = useState(false);
// Fetch Spoolman settings
const { data: settings, isLoading: settingsLoading } = useQuery({
@@ -66,19 +66,26 @@ export function SpoolmanSettings() {
setLocalEnabled(settings.spoolman_enabled === 'true');
setLocalUrl(settings.spoolman_url || '');
setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
setIsInitialized(true);
}
}, [settings]);
// Track changes
// Auto-save when settings change (after initial load)
useEffect(() => {
if (settings) {
const changed =
(settings.spoolman_enabled === 'true') !== localEnabled ||
(settings.spoolman_url || '') !== localUrl ||
(settings.spoolman_sync_mode || 'auto') !== localSyncMode;
setHasChanges(changed);
if (!isInitialized || !settings) return;
const hasChanges =
(settings.spoolman_enabled === 'true') !== localEnabled ||
(settings.spoolman_url || '') !== localUrl ||
(settings.spoolman_sync_mode || 'auto') !== localSyncMode;
if (hasChanges) {
const timeoutId = setTimeout(() => {
saveMutation.mutate();
}, 500);
return () => clearTimeout(timeoutId);
}
}, [settings, localEnabled, localUrl, localSyncMode]);
}, [localEnabled, localUrl, localSyncMode, isInitialized]);
// Save mutation
const saveMutation = useMutation({
@@ -91,7 +98,6 @@ export function SpoolmanSettings() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
setHasChanges(false);
setShowSaved(true);
setTimeout(() => setShowSaved(false), 2000);
},
@@ -173,20 +179,11 @@ export function SpoolmanSettings() {
<Database className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
</div>
{hasChanges && (
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : showSaved ? (
<Check className="w-4 h-4" />
) : (
'Save'
)}
</Button>
{saveMutation.isPending && (
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
)}
{showSaved && (
<Check className="w-4 h-4 text-bambu-green" />
)}
</div>
</CardHeader>
File diff suppressed because 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-Dyo2PpHq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BEeIB2d_.css">
<script type="module" crossorigin src="/assets/index-p0QddWWP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ob3MFXab.css">
</head>
<body>
<div id="root"></div>