admin: add backup/restore flow and structured request logging
This commit is contained in:
@@ -308,6 +308,7 @@ function AdminPage({
|
||||
const [form, setForm] = useState<LinkForm>(emptyForm());
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [restoreText, setRestoreText] = useState('');
|
||||
|
||||
const startEdit = (link: LinkItem) => {
|
||||
setEditingId(link.id);
|
||||
@@ -330,29 +331,92 @@ function AdminPage({
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||
<form
|
||||
className="panel space-y-2"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await onSave(form, file, editingId ?? undefined);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
|
||||
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
|
||||
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
|
||||
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="panel space-y-2"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await onSave(form, file, editingId ?? undefined);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
|
||||
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
|
||||
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
|
||||
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
<div className="panel space-y-2">
|
||||
<div className="text-xs text-slate-400">Backup / Restore</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-subtle"
|
||||
onClick={async () => {
|
||||
const backup = await api.request<Record<string, unknown>>('/api/admin/backup');
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `jellomator-backup-${new Date().toISOString().replaceAll(':', '-')}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}}
|
||||
>
|
||||
Export backup
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
onChange={async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
setRestoreText(await f.text());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
className="input min-h-32"
|
||||
placeholder="Paste backup JSON here"
|
||||
value={restoreText}
|
||||
onChange={(e) => setRestoreText(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-subtle"
|
||||
onClick={async () => {
|
||||
const data = JSON.parse(restoreText);
|
||||
await api.request('/api/admin/restore?dry_run=true', { method: 'POST', body: JSON.stringify({ data, confirm: false }) });
|
||||
alert('Restore dry-run passed.');
|
||||
}}
|
||||
>
|
||||
Validate (dry-run)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-subtle"
|
||||
onClick={async () => {
|
||||
if (!confirm('Apply restore now? This replaces existing users, sessions, and links.')) return;
|
||||
const data = JSON.parse(restoreText);
|
||||
await api.request('/api/admin/restore?dry_run=false', { method: 'POST', body: JSON.stringify({ data, confirm: true }) });
|
||||
alert('Restore applied.');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Apply restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<ul className="admin-list">
|
||||
|
||||
Reference in New Issue
Block a user