BillTracker/client/components/admin/BackupManagementCard.jsx

397 lines
16 KiB
React
Raw Normal View History

import React, { useState, useEffect, useCallback } from 'react';
import { Database, Download, Play, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmtBytes } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { SectionHeading, Toggle, formatDateTime, BackupTypeBadge } from './adminShared';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
const DEFAULT_SETTINGS = {
enabled: false,
frequency: 'daily',
time: '02:00',
retention_count: 2,
last_run_at: null,
next_run_at: null,
last_error: null,
};
export default function BackupManagementCard() {
const [backups, setBackups] = useState([]);
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState('');
const [restoreTarget, setRestoreTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [backupData, settingsData] = await Promise.all([
api.adminBackups(),
api.adminBackupSettings(),
]);
setBackups(backupData.backups || []);
setSettings({ ...DEFAULT_SETTINGS, ...settingsData });
} catch (err) {
toast.error(err.message || 'Failed to load backups.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const latest = backups[0];
const setSchedule = (key, value) => setSettings(prev => ({ ...prev, [key]: value }));
async function handleCreate() {
setBusy('create');
try {
await api.createAdminBackup();
toast.success('Backup created.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to create backup.');
} finally {
setBusy('');
}
}
async function handleDownload(backup) {
setBusy(`download:${backup.id}`);
try {
const { blob, filename } = await api.downloadAdminBackup(backup.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || backup.id;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
toast.error(err.message || 'Failed to download backup.');
} finally {
setBusy('');
}
}
async function handleImport(e) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setBusy('import');
try {
await api.importAdminBackup(file);
toast.success('Backup imported.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to import backup.');
} finally {
setBusy('');
}
}
async function handleRestore() {
if (!restoreTarget) return;
setBusy(`restore:${restoreTarget.id}`);
try {
const result = await api.restoreAdminBackup(restoreTarget.id);
toast.success(`Database restored. Pre-restore backup: ${result.pre_restore_backup}`);
setRestoreTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to restore backup.');
} finally {
setBusy('');
}
}
async function handleDelete() {
if (!deleteTarget) return;
setBusy(`delete:${deleteTarget.id}`);
try {
await api.deleteAdminBackup(deleteTarget.id);
toast.success('Backup deleted.');
setDeleteTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to delete backup.');
} finally {
setBusy('');
}
}
async function handleSaveSettings() {
setBusy('settings');
try {
const saved = await api.saveAdminBackupSettings({
enabled: !!settings.enabled,
frequency: settings.frequency,
time: settings.time,
retention_count: parseInt(settings.retention_count, 10) || 2,
});
setSettings({ ...DEFAULT_SETTINGS, ...saved });
toast.success('Backup schedule saved.');
} catch (err) {
toast.error(err.message || 'Failed to save backup schedule.');
} finally {
setBusy('');
}
}
async function handleRunScheduledNow() {
setBusy('run-scheduled');
try {
await api.runScheduledBackupNow();
toast.success('Scheduled backup created.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to run scheduled backup.');
} finally {
setBusy('');
}
}
if (loading) {
return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading backups</CardContent></Card>;
}
return (
<>
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Database Backups</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Admin-only SQLite backup, import, download, restore, and schedule controls.
</p>
</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge className={cn('cursor-default', settings.last_error ? 'bg-red-500/15 text-red-400 border-red-500/20' : 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20')}>
{settings.last_error ? 'Attention' : 'Ready'}
</Badge>
</TooltipTrigger>
<TooltipContent>{settings.last_error || 'Backup system is operational'}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-3 md:grid-cols-4">
{[
['Managed backups', backups.length],
['Latest backup', latest ? formatDateTime(latest.modified_at) : '—'],
['Latest size', latest ? fmtBytes(latest.size_bytes) : '—'],
['Scheduled', settings.enabled ? `${settings.frequency} ${settings.time}` : 'Disabled'],
].map(([label, value]) => (
<div key={label} className="rounded-lg border border-border bg-muted/20 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="text-sm font-medium mt-1 truncate">{value}</p>
</div>
))}
</div>
{settings.last_error && (
<div className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{settings.last_error}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button onClick={handleCreate} disabled={!!busy}>
<Database className="h-4 w-4" />
{busy === 'create' ? 'Creating…' : 'Create Backup'}
</Button>
<Button asChild variant="outline" disabled={!!busy}>
<label className={cn('cursor-pointer', busy && 'pointer-events-none opacity-50')}>
<Upload className="h-4 w-4" />
{busy === 'import' ? 'Importing…' : 'Import Backup'}
<input
type="file"
accept=".sqlite,.db,application/octet-stream,application/x-sqlite3,application/vnd.sqlite3"
onChange={handleImport}
className="sr-only"
disabled={!!busy}
/>
</label>
</Button>
<Button variant="outline" onClick={load} disabled={!!busy}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="min-w-[860px] w-full text-sm">
<thead className="bg-muted/40">
<tr className="border-b border-border">
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Backup</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Type</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Modified</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Size</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Checksum</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{backups.map(backup => (
<tr key={backup.id} className="border-b border-border last:border-0 hover:bg-muted/25">
<td className="px-4 py-3 font-mono text-xs max-w-[260px] truncate" title={backup.id}>{backup.id}</td>
<td className="px-4 py-3"><BackupTypeBadge type={backup.type} /></td>
<td className="px-4 py-3 text-muted-foreground">{formatDateTime(backup.modified_at)}</td>
<td className="px-4 py-3 font-mono">{fmtBytes(backup.size_bytes)}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[120px] truncate" title={backup.checksum}>
{backup.checksum || '—'}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button size="icon" variant="ghost" title="Download backup" onClick={() => handleDownload(backup)} disabled={!!busy}>
<Download className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title="Restore backup" onClick={() => setRestoreTarget(backup)} disabled={!!busy}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title="Delete backup" onClick={() => setDeleteTarget(backup)} disabled={!!busy}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
{!backups.length && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No managed backups yet.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="border-t border-border pt-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<SectionHeading>Scheduled Backups</SectionHeading>
<p className="text-xs text-muted-foreground mt-1">
Scheduled runs create managed backups and only apply retention to scheduled backups.
</p>
</div>
<Toggle checked={!!settings.enabled} onChange={v => setSchedule('enabled', v)} label="Enable scheduled backups" />
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-1.5">
<Label>Frequency</Label>
<Select value={settings.frequency} onValueChange={v => setSchedule('frequency', v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Time</Label>
<Input type="time" value={settings.time} onChange={e => setSchedule('time', e.target.value)} />
</div>
<div className="space-y-1.5">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Label className="cursor-default">Keep scheduled</Label>
</TooltipTrigger>
<TooltipContent>Number of scheduled backups to retain older ones are auto-deleted</TooltipContent>
</Tooltip>
</TooltipProvider>
<Input type="number" min="1" max="365" value={settings.retention_count} onChange={e => setSchedule('retention_count', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Next run</Label>
<div className="h-9 rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground truncate">
{formatDateTime(settings.next_run_at)}
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="text-sm text-muted-foreground">Last run: <span className="text-foreground">{formatDateTime(settings.last_run_at)}</span></div>
<div className="text-sm text-muted-foreground">Last error: <span className="text-foreground">{settings.last_error || '—'}</span></div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleRunScheduledNow} disabled={!!busy}>
<Play className="h-4 w-4" />
{busy === 'run-scheduled' ? 'Running…' : 'Run Scheduled Now'}
</Button>
<Button onClick={handleSaveSettings} disabled={!!busy}>
{busy === 'settings' ? 'Saving…' : 'Save Schedule'}
</Button>
</div>
</div>
</CardContent>
</Card>
<AlertDialog open={!!restoreTarget} onOpenChange={(open) => { if (!open) setRestoreTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore this database backup?</AlertDialogTitle>
<AlertDialogDescription>
This replaces the live database with <span className="font-mono">{restoreTarget?.id}</span>.
A pre-restore backup will be created first. Run this during a quiet maintenance window.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy.startsWith('restore:')}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleRestore}
disabled={busy.startsWith('restore:')}
>
{busy.startsWith('restore:') ? 'Restoring…' : 'Restore Database'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this backup?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes <span className="font-mono">{deleteTarget?.id}</span>.
The live database is not affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy.startsWith('delete:')}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={busy.startsWith('delete:')}
>
{busy.startsWith('delete:') ? 'Deleting…' : 'Delete Backup'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}