mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
- Improved backup/restore module
This commit is contained in:
@@ -427,9 +427,10 @@ async def export_backup(
|
||||
@router.post("/restore")
|
||||
async def import_backup(
|
||||
file: UploadFile = File(...),
|
||||
overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Restore data from JSON or ZIP backup. Skips duplicates."""
|
||||
"""Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
|
||||
try:
|
||||
content = await file.read()
|
||||
base_dir = app_settings.base_dir
|
||||
@@ -477,21 +478,64 @@ async def import_backup(
|
||||
"filaments": 0,
|
||||
"maintenance_types": 0,
|
||||
}
|
||||
skipped = {
|
||||
"settings": 0,
|
||||
"notification_providers": 0,
|
||||
"notification_templates": 0,
|
||||
"smart_plugs": 0,
|
||||
"printers": 0,
|
||||
"filaments": 0,
|
||||
"maintenance_types": 0,
|
||||
"archives": 0,
|
||||
}
|
||||
skipped_details = {
|
||||
"notification_providers": [],
|
||||
"smart_plugs": [],
|
||||
"printers": [],
|
||||
"filaments": [],
|
||||
"maintenance_types": [],
|
||||
"archives": [],
|
||||
}
|
||||
|
||||
# Restore settings
|
||||
# Restore settings (always overwrites)
|
||||
if "settings" in backup:
|
||||
for key, value in backup["settings"].items():
|
||||
await set_setting(db, key, value)
|
||||
restored["settings"] += 1
|
||||
|
||||
# Restore notification providers (skip duplicates by name)
|
||||
# Restore notification providers (skip or overwrite duplicates by name)
|
||||
if "notification_providers" in backup:
|
||||
for provider_data in backup["notification_providers"]:
|
||||
result = await db.execute(
|
||||
select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if not existing:
|
||||
if existing:
|
||||
if overwrite:
|
||||
# Update existing provider
|
||||
existing.provider_type = provider_data["provider_type"]
|
||||
existing.enabled = provider_data.get("enabled", True)
|
||||
existing.config = json.dumps(provider_data.get("config", {}))
|
||||
existing.on_print_start = provider_data.get("on_print_start", False)
|
||||
existing.on_print_complete = provider_data.get("on_print_complete", True)
|
||||
existing.on_print_failed = provider_data.get("on_print_failed", True)
|
||||
existing.on_print_stopped = provider_data.get("on_print_stopped", True)
|
||||
existing.on_print_progress = provider_data.get("on_print_progress", False)
|
||||
existing.on_printer_offline = provider_data.get("on_printer_offline", False)
|
||||
existing.on_printer_error = provider_data.get("on_printer_error", False)
|
||||
existing.on_filament_low = provider_data.get("on_filament_low", False)
|
||||
existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
|
||||
existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
|
||||
existing.quiet_hours_start = provider_data.get("quiet_hours_start")
|
||||
existing.quiet_hours_end = provider_data.get("quiet_hours_end")
|
||||
existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
|
||||
existing.daily_digest_time = provider_data.get("daily_digest_time")
|
||||
existing.printer_id = provider_data.get("printer_id")
|
||||
restored["notification_providers"] += 1
|
||||
else:
|
||||
skipped["notification_providers"] += 1
|
||||
skipped_details["notification_providers"].append(provider_data["name"])
|
||||
else:
|
||||
provider = NotificationProvider(
|
||||
name=provider_data["name"],
|
||||
provider_type=provider_data["provider_type"],
|
||||
@@ -542,14 +586,36 @@ async def import_backup(
|
||||
db.add(template)
|
||||
restored["notification_templates"] += 1
|
||||
|
||||
# Restore smart plugs (skip duplicates by IP)
|
||||
# Restore smart plugs (skip or overwrite duplicates by IP)
|
||||
if "smart_plugs" in backup:
|
||||
for plug_data in backup["smart_plugs"]:
|
||||
result = await db.execute(
|
||||
select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if not existing:
|
||||
if existing:
|
||||
if overwrite:
|
||||
existing.name = plug_data["name"]
|
||||
existing.printer_id = plug_data.get("printer_id")
|
||||
existing.enabled = plug_data.get("enabled", True)
|
||||
existing.auto_on = plug_data.get("auto_on", True)
|
||||
existing.auto_off = plug_data.get("auto_off", True)
|
||||
existing.off_delay_mode = plug_data.get("off_delay_mode", "time")
|
||||
existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5)
|
||||
existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70)
|
||||
existing.username = plug_data.get("username")
|
||||
existing.password = plug_data.get("password")
|
||||
existing.power_alert_enabled = plug_data.get("power_alert_enabled", False)
|
||||
existing.power_alert_high = plug_data.get("power_alert_high")
|
||||
existing.power_alert_low = plug_data.get("power_alert_low")
|
||||
existing.schedule_enabled = plug_data.get("schedule_enabled", False)
|
||||
existing.schedule_on_time = plug_data.get("schedule_on_time")
|
||||
existing.schedule_off_time = plug_data.get("schedule_off_time")
|
||||
restored["smart_plugs"] += 1
|
||||
else:
|
||||
skipped["smart_plugs"] += 1
|
||||
skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_data['ip_address']})")
|
||||
else:
|
||||
plug = SmartPlug(
|
||||
name=plug_data["name"],
|
||||
ip_address=plug_data["ip_address"],
|
||||
@@ -572,14 +638,29 @@ async def import_backup(
|
||||
db.add(plug)
|
||||
restored["smart_plugs"] += 1
|
||||
|
||||
# Restore printers (skip duplicates by serial_number, requires access_code to be set manually)
|
||||
# Restore printers (skip or overwrite duplicates by serial_number)
|
||||
# Note: access_code is never restored for security - must 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:
|
||||
if existing:
|
||||
if overwrite:
|
||||
existing.name = printer_data["name"]
|
||||
existing.ip_address = printer_data["ip_address"]
|
||||
existing.model = printer_data.get("model")
|
||||
existing.location = printer_data.get("location")
|
||||
existing.nozzle_count = printer_data.get("nozzle_count", 1)
|
||||
existing.auto_archive = printer_data.get("auto_archive", True)
|
||||
existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
|
||||
# Don't overwrite access_code or is_active to preserve working connection
|
||||
restored["printers"] += 1
|
||||
else:
|
||||
skipped["printers"] += 1
|
||||
skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
|
||||
else:
|
||||
printer = Printer(
|
||||
name=printer_data["name"],
|
||||
serial_number=printer_data["serial_number"],
|
||||
@@ -595,7 +676,7 @@ async def import_backup(
|
||||
db.add(printer)
|
||||
restored["printers"] += 1
|
||||
|
||||
# Restore filaments (skip duplicates by name+type+brand)
|
||||
# Restore filaments (skip or overwrite duplicates by name+type+brand)
|
||||
if "filaments" in backup:
|
||||
for filament_data in backup["filaments"]:
|
||||
result = await db.execute(
|
||||
@@ -606,7 +687,23 @@ async def import_backup(
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if not existing:
|
||||
if existing:
|
||||
if overwrite:
|
||||
existing.color = filament_data.get("color")
|
||||
existing.color_hex = filament_data.get("color_hex")
|
||||
existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0)
|
||||
existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0)
|
||||
existing.currency = filament_data.get("currency", "USD")
|
||||
existing.density = filament_data.get("density")
|
||||
existing.print_temp_min = filament_data.get("print_temp_min")
|
||||
existing.print_temp_max = filament_data.get("print_temp_max")
|
||||
existing.bed_temp_min = filament_data.get("bed_temp_min")
|
||||
existing.bed_temp_max = filament_data.get("bed_temp_max")
|
||||
restored["filaments"] += 1
|
||||
else:
|
||||
skipped["filaments"] += 1
|
||||
skipped_details["filaments"].append(f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})")
|
||||
else:
|
||||
filament = Filament(
|
||||
name=filament_data["name"],
|
||||
type=filament_data["type"],
|
||||
@@ -625,14 +722,25 @@ async def import_backup(
|
||||
db.add(filament)
|
||||
restored["filaments"] += 1
|
||||
|
||||
# Restore maintenance types (skip duplicates by name)
|
||||
# Restore maintenance types (skip or overwrite 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:
|
||||
if existing:
|
||||
if overwrite:
|
||||
existing.description = mt_data.get("description")
|
||||
existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0)
|
||||
existing.interval_type = mt_data.get("interval_type", "hours")
|
||||
existing.icon = mt_data.get("icon")
|
||||
# Don't overwrite is_system
|
||||
restored["maintenance_types"] += 1
|
||||
else:
|
||||
skipped["maintenance_types"] += 1
|
||||
skipped_details["maintenance_types"].append(mt_data["name"])
|
||||
else:
|
||||
mt = MaintenanceType(
|
||||
name=mt_data["name"],
|
||||
description=mt_data.get("description"),
|
||||
@@ -644,7 +752,7 @@ async def import_backup(
|
||||
db.add(mt)
|
||||
restored["maintenance_types"] += 1
|
||||
|
||||
# Restore archives (skip duplicates by content_hash)
|
||||
# Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
|
||||
if "archives" in backup:
|
||||
for archive_data in backup["archives"]:
|
||||
# Skip if no content_hash or already exists
|
||||
@@ -655,6 +763,8 @@ async def import_backup(
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
skipped["archives"] += 1
|
||||
skipped_details["archives"].append(archive_data.get("filename", "Unknown"))
|
||||
continue
|
||||
|
||||
# Only restore if file exists (from ZIP extraction)
|
||||
@@ -697,17 +807,32 @@ async def import_backup(
|
||||
await db.commit()
|
||||
|
||||
# Build summary message
|
||||
parts = []
|
||||
restored_parts = []
|
||||
for key, count in restored.items():
|
||||
if count > 0:
|
||||
parts.append(f"{count} {key.replace('_', ' ')}")
|
||||
restored_parts.append(f"{count} {key.replace('_', ' ')}")
|
||||
|
||||
if files_restored > 0:
|
||||
parts.append(f"{files_restored} files")
|
||||
restored_parts.append(f"{files_restored} files")
|
||||
|
||||
skipped_parts = []
|
||||
total_skipped = sum(skipped.values())
|
||||
for key, count in skipped.items():
|
||||
if count > 0:
|
||||
skipped_parts.append(f"{count} {key.replace('_', ' ')}")
|
||||
|
||||
message_parts = []
|
||||
if restored_parts:
|
||||
message_parts.append(f"Restored: {', '.join(restored_parts)}")
|
||||
if skipped_parts:
|
||||
message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Restored: {', '.join(parts)}" if parts else "Nothing to restore",
|
||||
"message": ". ".join(message_parts) if message_parts else "Nothing to restore",
|
||||
"restored": restored,
|
||||
"skipped": skipped,
|
||||
"skipped_details": skipped_details,
|
||||
"files_restored": files_restored,
|
||||
"total_skipped": total_skipped,
|
||||
}
|
||||
|
||||
@@ -1175,17 +1175,22 @@ export const api = {
|
||||
const blob = await response.blob();
|
||||
return { blob, filename };
|
||||
},
|
||||
importBackup: async (file: File) => {
|
||||
importBackup: async (file: File, overwrite = false) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch(`${API_BASE}/settings/restore`, {
|
||||
const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
return response.json() as Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
restored?: { settings: number; notification_providers: number; smart_plugs: number };
|
||||
restored?: Record<string, number>;
|
||||
skipped?: Record<string, number>;
|
||||
skipped_details?: Record<string, string[]>;
|
||||
files_restored?: number;
|
||||
total_skipped?: number;
|
||||
}>;
|
||||
},
|
||||
checkFfmpeg: () =>
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Upload, X, AlertTriangle, CheckCircle, SkipForward, RefreshCw, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
interface RestoreResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
restored?: Record<string, number>;
|
||||
skipped?: Record<string, number>;
|
||||
skipped_details?: Record<string, string[]>;
|
||||
files_restored?: number;
|
||||
total_skipped?: number;
|
||||
}
|
||||
|
||||
interface RestoreModalProps {
|
||||
onClose: () => void;
|
||||
onRestore: (file: File, overwrite: boolean) => Promise<RestoreResult>;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type ModalState = 'options' | 'restoring' | 'result';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
settings: 'Settings',
|
||||
notification_providers: 'Notification Providers',
|
||||
notification_templates: 'Notification Templates',
|
||||
smart_plugs: 'Smart Plugs',
|
||||
printers: 'Printers',
|
||||
filaments: 'Filaments',
|
||||
maintenance_types: 'Maintenance Types',
|
||||
archives: 'Archives',
|
||||
};
|
||||
|
||||
export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
|
||||
const [state, setState] = useState<ModalState>('options');
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<RestoreResult | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && state !== 'restoring') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, state]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setState('restoring');
|
||||
try {
|
||||
const restoreResult = await onRestore(selectedFile, overwrite);
|
||||
setResult(restoreResult);
|
||||
setState('result');
|
||||
if (restoreResult.success) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch {
|
||||
setResult({
|
||||
success: false,
|
||||
message: 'Failed to restore backup. Please check the file format.',
|
||||
});
|
||||
setState('result');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const totalRestored = result?.restored
|
||||
? Object.values(result.restored).reduce((a, b) => a + b, 0) + (result.files_restored || 0)
|
||||
: 0;
|
||||
const totalSkipped = result?.total_skipped || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={state !== 'restoring' ? onClose : undefined}
|
||||
>
|
||||
<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 ${
|
||||
state === 'result' && result?.success
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: state === 'result' && !result?.success
|
||||
? 'bg-red-500/20 text-red-500'
|
||||
: 'bg-blue-500/20 text-blue-500'
|
||||
}`}>
|
||||
{state === 'result' && result?.success ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : state === 'result' && !result?.success ? (
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
) : (
|
||||
<Upload className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{state === 'options' && 'Restore Backup'}
|
||||
{state === 'restoring' && 'Restoring...'}
|
||||
{state === 'result' && (result?.success ? 'Restore Complete' : 'Restore Failed')}
|
||||
</h3>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{state === 'options' && 'Import settings from a backup file'}
|
||||
{state === 'restoring' && 'Please wait while your data is being restored'}
|
||||
{state === 'result' && result?.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{state !== 'restoring' && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options State */}
|
||||
{state === 'options' && (
|
||||
<>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* File Selection */}
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.zip"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
|
||||
selectedFile
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<div className="flex items-center justify-center gap-2 text-bambu-green">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{selectedFile.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-bambu-gray">
|
||||
<Upload className="w-8 h-8" />
|
||||
<span>Click to select backup file (.json or .zip)</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-blue-700 dark:text-blue-200">
|
||||
<p className="font-medium mb-1">How duplicate handling works:</p>
|
||||
<ul className="text-blue-600 dark:text-blue-200/80 space-y-1 text-xs">
|
||||
<li><strong>Printers</strong> - matched by serial number</li>
|
||||
<li><strong>Smart Plugs</strong> - matched by IP address</li>
|
||||
<li><strong>Notification Providers</strong> - matched by name</li>
|
||||
<li><strong>Filaments</strong> - matched by name + type + brand</li>
|
||||
<li><strong>Archives</strong> - matched by content hash (always skipped)</li>
|
||||
<li><strong>Settings & Templates</strong> - always overwritten</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overwrite Toggle */}
|
||||
<div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white font-medium flex items-center gap-2">
|
||||
{overwrite ? (
|
||||
<RefreshCw className="w-4 h-4 text-orange-400" />
|
||||
) : (
|
||||
<SkipForward className="w-4 h-4 text-bambu-gray" />
|
||||
)}
|
||||
{overwrite ? 'Overwrite existing data' : 'Skip duplicates'}
|
||||
</p>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
{overwrite
|
||||
? 'Replace existing items with data from backup (except access codes)'
|
||||
: 'Keep existing items, only add new ones from backup'}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={overwrite} onChange={setOverwrite} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overwrite && (
|
||||
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-orange-700 dark:text-orange-200">
|
||||
<span className="font-medium">Caution:</span> Overwriting will replace your current configurations with data from the backup. Printer access codes are never overwritten for security.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRestore}
|
||||
disabled={!selectedFile}
|
||||
className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Restoring State */}
|
||||
{state === 'restoring' && (
|
||||
<div className="p-8 flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-12 h-12 text-bambu-green animate-spin" />
|
||||
<p className="text-bambu-gray">Processing backup file...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result State */}
|
||||
{state === 'result' && result && (
|
||||
<>
|
||||
<div className="p-4 space-y-4 max-h-[400px] overflow-y-auto">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-bambu-green/10 border border-bambu-green/30">
|
||||
<div className="text-2xl font-bold text-bambu-green">{totalRestored}</div>
|
||||
<div className="text-sm text-bambu-gray">Items Restored</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
||||
<div className="text-2xl font-bold text-yellow-500">{totalSkipped}</div>
|
||||
<div className="text-sm text-bambu-gray">Items Skipped</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restored Details */}
|
||||
{result.restored && Object.entries(result.restored).some(([, count]) => count > 0) && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green" />
|
||||
Restored
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(result.restored)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
|
||||
<span className="text-white">{CATEGORY_LABELS[key] || key}</span>
|
||||
<span className="text-bambu-green font-medium">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
{(result.files_restored || 0) > 0 && (
|
||||
<div className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
|
||||
<span className="text-white">Files (3MF, thumbnails, etc.)</span>
|
||||
<span className="text-bambu-green font-medium">{result.files_restored}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skipped Details */}
|
||||
{result.skipped && Object.entries(result.skipped).some(([, count]) => count > 0) && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
|
||||
<SkipForward className="w-4 h-4 text-yellow-500" />
|
||||
Skipped (already exist)
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(result.skipped)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => {
|
||||
const details = result.skipped_details?.[key] || [];
|
||||
const isExpanded = expandedCategories.has(key);
|
||||
return (
|
||||
<div key={key}>
|
||||
<button
|
||||
onClick={() => details.length > 0 && toggleCategory(key)}
|
||||
className={`w-full flex items-center justify-between text-sm p-2 rounded bg-bambu-dark ${
|
||||
details.length > 0 ? 'hover:bg-bambu-dark-tertiary cursor-pointer' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-white flex items-center gap-2">
|
||||
{CATEGORY_LABELS[key] || key}
|
||||
{details.length > 0 && (
|
||||
isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-yellow-500 font-medium">{count}</span>
|
||||
</button>
|
||||
{isExpanded && details.length > 0 && (
|
||||
<div className="mt-1 ml-4 p-2 rounded bg-bambu-dark-tertiary text-xs text-bambu-gray space-y-1">
|
||||
{details.slice(0, 10).map((item, i) => (
|
||||
<div key={i}>{item}</div>
|
||||
))}
|
||||
{details.length > 10 && (
|
||||
<div className="text-bambu-gray/60">...and {details.length - 10} more</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalRestored === 0 && totalSkipped === 0 && (
|
||||
<div className="p-4 text-center text-bambu-gray">
|
||||
No data was found to restore in the backup file.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { NotificationTemplateEditor } from '../components/NotificationTemplateEd
|
||||
import { NotificationLogViewer } from '../components/NotificationLogViewer';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { BackupModal } from '../components/BackupModal';
|
||||
import { RestoreModal } from '../components/RestoreModal';
|
||||
import { SpoolmanSettings } from '../components/SpoolmanSettings';
|
||||
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
|
||||
import { availableLanguages } from '../i18n';
|
||||
@@ -31,7 +32,6 @@ export function SettingsPage() {
|
||||
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
|
||||
const [showLogViewer, setShowLogViewer] = useState(false);
|
||||
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
|
||||
|
||||
// Confirm modal states
|
||||
@@ -39,6 +39,7 @@ export function SettingsPage() {
|
||||
const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
|
||||
const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
|
||||
const [showBackupModal, setShowBackupModal] = useState(false);
|
||||
const [showRestoreModal, setShowRestoreModal] = useState(false);
|
||||
|
||||
const handleDefaultViewChange = (path: string) => {
|
||||
setDefaultViewState(path);
|
||||
@@ -884,43 +885,19 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Restore Settings</p>
|
||||
<p className="text-white">Restore Backup</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Import settings from a backup file
|
||||
Import settings from a backup file with duplicate handling options
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.zip"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await api.importBackup(file);
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
queryClient.invalidateQueries();
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Failed to restore backup', 'error');
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowRestoreModal(true)}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
@@ -943,9 +920,9 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Clear Local Storage</p>
|
||||
<p className="text-white">Reset UI Preferences</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Clear browser cache (sidebar order, preferences)
|
||||
Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -954,7 +931,7 @@ export function SettingsPage() {
|
||||
onClick={() => setShowClearStorageConfirm(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1423,14 +1400,14 @@ export function SettingsPage() {
|
||||
{/* Confirm Modal: Clear Local Storage */}
|
||||
{showClearStorageConfirm && (
|
||||
<ConfirmModal
|
||||
title="Clear All Local Storage"
|
||||
message="WARNING: This will clear ALL browser data for Bambuddy including your sidebar order, preferences, and cached data. The page will reload after clearing. This action cannot be undone!"
|
||||
confirmText="Clear Everything"
|
||||
variant="danger"
|
||||
title="Reset UI Preferences"
|
||||
message="This will reset all UI preferences to defaults: sidebar order, theme, dashboard layout, view modes, and sorting preferences. Your printers, archives, and server settings will NOT be affected. The page will reload after clearing."
|
||||
confirmText="Reset Preferences"
|
||||
variant="default"
|
||||
onConfirm={() => {
|
||||
setShowClearStorageConfirm(false);
|
||||
localStorage.clear();
|
||||
showToast('Local storage cleared. Refreshing...', 'success');
|
||||
showToast('UI preferences reset. Refreshing...', 'success');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
}}
|
||||
onCancel={() => setShowClearStorageConfirm(false)}
|
||||
@@ -1474,6 +1451,19 @@ export function SettingsPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Restore Modal */}
|
||||
{showRestoreModal && (
|
||||
<RestoreModal
|
||||
onClose={() => setShowRestoreModal(false)}
|
||||
onRestore={async (file, overwrite) => {
|
||||
return await api.importBackup(file, overwrite);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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-C1C_HwA0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-79rMOukP.css">
|
||||
<script type="module" crossorigin src="/assets/index-Dyo2PpHq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BEeIB2d_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user