mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
- Improved backup/restore module
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user