feat: fix export/import, add backup download/upload, restore service checkboxes

- export_config: clean output (no internal _keys), identity exposed as 'identity'
- import_config: handle 'identity' key, merge into existing config (not replace)
- restore_config: accept optional services list for selective restore
- backup_config: include 'identity' in manifest services list
- new GET /api/config/backups/<id>/download → zip file download
- new POST /api/config/backup/upload → zip file upload
- webui: Download + Upload buttons, restore modal with per-service checkboxes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 08:51:40 -04:00
parent 2bd6545f0e
commit 15e009bd94
4 changed files with 281 additions and 36 deletions
+147 -13
View File
@@ -5,10 +5,23 @@ import {
Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar,
HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw,
ChevronDown, ChevronRight, CheckCircle, XCircle,
RefreshCw, Lock
RefreshCw, Lock, FolderDown, X
} from 'lucide-react';
import { cellAPI } from '../services/api';
// ── constants ────────────────────────────────────────────────────────────────
const RESTORE_SERVICES = [
{ key: 'identity', label: 'Identity (cell name, domain, IP range)' },
{ key: 'network', label: 'Network (DNS, DHCP, NTP)' },
{ key: 'wireguard', label: 'WireGuard VPN' },
{ key: 'email', label: 'Email' },
{ key: 'calendar', label: 'Calendar & Contacts' },
{ key: 'files', label: 'File Storage' },
{ key: 'routing', label: 'Routing' },
{ key: 'vault', label: 'Vault & Certificates' },
];
// ── helpers ──────────────────────────────────────────────────────────────────
function toast(msg, type = 'success') {
@@ -413,6 +426,9 @@ function Settings() {
const [backups, setBackups] = useState([]);
const [backupsLoading, setBackupsLoading] = useState(false);
const [backupCreating, setBackupCreating] = useState(false);
const [restoreModal, setRestoreModal] = useState(null); // backup object or null
const [restoreServices, setRestoreServices] = useState(new Set(RESTORE_SERVICES.map(s => s.key)));
const [backupUploading, setBackupUploading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -566,17 +582,56 @@ function Settings() {
}
};
const restoreBackup = async (id) => {
if (!confirm(`Restore backup ${id}? Current config will be overwritten.`)) return;
const openRestoreModal = (backup) => {
setRestoreServices(new Set(RESTORE_SERVICES.map(s => s.key)));
setRestoreModal(backup);
};
const doRestore = async () => {
if (!restoreModal) return;
const allSelected = restoreServices.size === RESTORE_SERVICES.length;
const services = allSelected ? null : Array.from(restoreServices);
try {
await cellAPI.restoreBackup(id);
await cellAPI.restoreBackup(restoreModal.backup_id, services);
toast('Configuration restored — reloading…');
setRestoreModal(null);
setTimeout(() => loadAll(), 500);
} catch {
toast('Failed to restore backup', 'error');
}
};
const downloadBackup = async (id) => {
try {
const res = await cellAPI.downloadBackup(id);
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `${id}.zip`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast('Download failed', 'error');
}
};
const uploadBackup = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
setBackupUploading(true);
try {
await cellAPI.uploadBackup(file);
toast('Backup uploaded');
const res = await cellAPI.listBackups();
setBackups(res.data || []);
} catch {
toast('Upload failed — ensure it is a valid backup zip', 'error');
} finally {
setBackupUploading(false);
}
};
const deleteBackup = async (id) => {
if (!confirm(`Delete backup ${id}?`)) return;
try {
@@ -689,14 +744,21 @@ function Settings() {
<Section icon={Archive} title="Backup & Restore" collapsible defaultOpen>
<div className="flex justify-between items-center mb-3">
<span className="text-sm text-gray-600">{backups.length} backup{backups.length !== 1 ? 's' : ''} stored</span>
<button
onClick={createBackup}
disabled={backupCreating}
className="btn-secondary flex items-center gap-2 text-sm"
>
{backupCreating ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Archive className="h-4 w-4" />}
Create Backup
</button>
<div className="flex gap-2">
<label className="btn-secondary flex items-center gap-2 text-sm cursor-pointer" title="Upload backup zip">
{backupUploading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Upload
<input type="file" accept=".zip" className="hidden" onChange={uploadBackup} />
</label>
<button
onClick={createBackup}
disabled={backupCreating}
className="btn-secondary flex items-center gap-2 text-sm"
>
{backupCreating ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Archive className="h-4 w-4" />}
Create Backup
</button>
</div>
</div>
{backups.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No backups yet</p>
@@ -720,7 +782,14 @@ function Settings() {
<td className="py-2">
<div className="flex gap-2 justify-end">
<button
onClick={() => restoreBackup(b.backup_id)}
onClick={() => downloadBackup(b.backup_id)}
className="text-gray-500 hover:text-gray-700 flex items-center gap-1 text-xs"
title="Download"
>
<FolderDown className="h-3.5 w-3.5" /> Download
</button>
<button
onClick={() => openRestoreModal(b)}
className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-xs"
title="Restore"
>
@@ -743,6 +812,71 @@ function Settings() {
)}
</Section>
{/* Restore modal */}
{restoreModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-96 max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold text-gray-900">Restore Backup</h3>
<p className="text-xs text-gray-500 mt-0.5 font-mono">{restoreModal.backup_id}</p>
</div>
<button onClick={() => setRestoreModal(null)} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-gray-600 mb-3">Select which services to restore:</p>
<div className="space-y-2 mb-4">
{RESTORE_SERVICES.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={restoreServices.has(key)}
onChange={(e) => {
setRestoreServices((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(key);
else next.delete(key);
return next;
});
}}
className="h-4 w-4 rounded"
/>
<span className="text-sm text-gray-700">{label}</span>
</label>
))}
</div>
<div className="flex justify-between items-center mb-4">
<button
onClick={() => setRestoreServices(new Set(RESTORE_SERVICES.map(s => s.key)))}
className="text-xs text-blue-600 hover:text-blue-800"
>
Select all
</button>
<button
onClick={() => setRestoreServices(new Set())}
className="text-xs text-gray-500 hover:text-gray-700"
>
Deselect all
</button>
</div>
{restoreServices.size === RESTORE_SERVICES.length && (
<p className="text-xs text-gray-400 mb-4">All services selected full restore including system files.</p>
)}
<div className="flex gap-2 justify-end">
<button onClick={() => setRestoreModal(null)} className="btn-secondary text-sm">Cancel</button>
<button
onClick={doRestore}
disabled={restoreServices.size === 0}
className="btn-primary text-sm disabled:opacity-50"
>
Restore Selected
</button>
</div>
</div>
</div>
)}
{/* Export / Import */}
<Section icon={Download} title="Export & Import">
<div className="flex flex-wrap gap-3">