Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
v0.6.1 — Backup tooling (.swb ZIP export/import), admin backup section, docs surface with markdown rendering, 5 reference docs, 6 handler tests. v0.6.2 — Dark mode contrast fixes, topbar nav, 📖 docs icon, dynamic OpenAPI spec with extension route merging, docs outline sidebar, scroll/routing fixes; 7 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
163 lines
6.3 KiB
JavaScript
163 lines
6.3 KiB
JavaScript
/**
|
|
* Admin > System > Backup
|
|
*
|
|
* Create, download, and restore .swb backup archives.
|
|
*/
|
|
const { html } = window;
|
|
const { useState, useEffect, useCallback } = hooks;
|
|
|
|
export default function BackupSection() {
|
|
const [backups, setBackups] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [creating, setCreating] = useState(false);
|
|
const [restoring, setRestoring] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const resp = await sw.api.admin.backup.list();
|
|
setBackups(Array.isArray(resp) ? resp : resp.data || []);
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
async function createBackup(store) {
|
|
setCreating(true);
|
|
try {
|
|
if (store) {
|
|
await sw.api.admin.backup.create({ store: 'true' });
|
|
sw.toast('Backup created', 'success');
|
|
load();
|
|
} else {
|
|
// Stream download
|
|
const url = '/api/v1/admin/backup';
|
|
const token = sw.auth.token();
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + token },
|
|
});
|
|
if (!resp.ok) throw new Error('Backup failed');
|
|
const blob = await resp.blob();
|
|
const disposition = resp.headers.get('Content-Disposition') || '';
|
|
const match = disposition.match(/filename="?([^"]+)"?/);
|
|
const filename = match ? match[1] : 'backup.swb';
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
sw.toast('Backup downloaded', 'success');
|
|
}
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
finally { setCreating(false); }
|
|
}
|
|
|
|
async function downloadBackup(name) {
|
|
const token = sw.auth.token();
|
|
const resp = await fetch(sw.api.admin.backup.download(name), {
|
|
headers: { 'Authorization': 'Bearer ' + token },
|
|
});
|
|
if (!resp.ok) { sw.toast('Download failed', 'error'); return; }
|
|
const blob = await resp.blob();
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = name;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
|
|
async function deleteBackup(name) {
|
|
const ok = await sw.confirm(`Delete backup "${name}"?`);
|
|
if (!ok) return;
|
|
try {
|
|
await sw.api.admin.backup.del(name);
|
|
sw.toast('Backup deleted', 'success');
|
|
load();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function restoreBackup() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.swb';
|
|
input.onchange = async () => {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
const ok = await sw.confirm(
|
|
`Restore from "${file.name}"? This will replace ALL data in the database. This action cannot be undone.`
|
|
);
|
|
if (!ok) return;
|
|
setRestoring(true);
|
|
try {
|
|
const resp = await sw.api.admin.backup.restore(file);
|
|
const d = resp.data || resp;
|
|
sw.toast(`Restored ${d.rows_restored || 0} rows`, 'success');
|
|
load();
|
|
} catch (e) { sw.toast('Restore failed: ' + e.message, 'error'); }
|
|
finally { setRestoring(false); }
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
function formatBytes(b) {
|
|
if (!b) return '0 B';
|
|
if (b < 1024) return b + ' B';
|
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
|
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
|
|
return (b / 1073741824).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function formatDate(d) {
|
|
if (!d) return '\u2014';
|
|
return new Date(d).toLocaleString();
|
|
}
|
|
|
|
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
|
|
|
return html`
|
|
<div>
|
|
<div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;">
|
|
<button class="btn btn-primary" onclick=${() => createBackup(false)} disabled=${creating || restoring}>
|
|
${creating ? 'Creating\u2026' : 'Download Backup'}
|
|
</button>
|
|
<button class="btn" onclick=${() => createBackup(true)} disabled=${creating || restoring}>
|
|
${creating ? 'Creating\u2026' : 'Save to Server'}
|
|
</button>
|
|
<button class="btn btn-danger" onclick=${restoreBackup} disabled=${creating || restoring}>
|
|
${restoring ? 'Restoring\u2026' : 'Restore from File'}
|
|
</button>
|
|
</div>
|
|
|
|
<h3 style="margin:0 0 12px;">Server Backups</h3>
|
|
${backups.length === 0 ? html`
|
|
<p style="color:var(--text-secondary);">No server-side backups. Use "Save to Server" to create one.</p>
|
|
` : html`
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Size</th>
|
|
<th>Created</th>
|
|
<th style="width:120px;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${backups.map(b => html`
|
|
<tr key=${b.name}>
|
|
<td><code>${b.name}</code></td>
|
|
<td>${formatBytes(b.size)}</td>
|
|
<td>${formatDate(b.created_at)}</td>
|
|
<td>
|
|
<button class="btn btn-sm" onclick=${() => downloadBackup(b.name)}>Download</button>
|
|
<button class="btn btn-sm btn-danger" onclick=${() => deleteBackup(b.name)} style="margin-left:4px;">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`)}
|
|
</tbody>
|
|
</table>
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|