BillTracker/client/components/admin/BackupManagementCard.tsx

532 lines
19 KiB
TypeScript
Raw Permalink Normal View History

import { 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, errMessage } 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';
interface Backup {
id: string;
type?: string;
modified_at?: string | null;
size_bytes?: number;
checksum?: string | null;
}
interface BackupSettings {
enabled: boolean;
frequency: string;
time: string;
retention_count: number | string;
last_run_at: string | null;
next_run_at: string | null;
last_error: string | null;
}
const DEFAULT_SETTINGS: BackupSettings = {
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<Backup[]>([]);
const [settings, setSettings] = useState<BackupSettings>(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState('');
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [backupData, settingsData] = await Promise.all([
api.adminBackups() as Promise<{ backups?: Backup[] }>,
api.adminBackupSettings() as Promise<Partial<BackupSettings>>,
]);
setBackups(backupData.backups || []);
setSettings({ ...DEFAULT_SETTINGS, ...settingsData });
} catch (err) {
toast.error(errMessage(err, 'Failed to load backups.'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const latest = backups[0];
const setSchedule = (key: keyof BackupSettings, value: string | boolean) =>
setSettings((prev) => ({ ...prev, [key]: value }));
async function handleCreate() {
setBusy('create');
try {
await api.createAdminBackup();
toast.success('Backup created.');
await load();
} catch (err) {
toast.error(errMessage(err, 'Failed to create backup.'));
} finally {
setBusy('');
}
}
async function handleDownload(backup: Backup) {
setBusy(`download:${backup.id}`);
try {
const { blob, filename } = (await api.downloadAdminBackup(backup.id)) as {
blob: Blob;
filename?: string;
};
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(errMessage(err, 'Failed to download backup.'));
} finally {
setBusy('');
}
}
async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
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(errMessage(err, 'Failed to import backup.'));
} finally {
setBusy('');
}
}
async function handleRestore() {
if (!restoreTarget) return;
setBusy(`restore:${restoreTarget.id}`);
try {
const result = (await api.restoreAdminBackup(restoreTarget.id)) as {
pre_restore_backup?: string;
};
toast.success(`Database restored. Pre-restore backup: ${result.pre_restore_backup}`);
setRestoreTarget(null);
await load();
} catch (err) {
toast.error(errMessage(err, '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(errMessage(err, '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(String(settings.retention_count), 10) || 2,
})) as Partial<BackupSettings>;
setSettings({ ...DEFAULT_SETTINGS, ...saved });
toast.success('Backup schedule saved.');
} catch (err) {
toast.error(errMessage(err, '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(errMessage(err, '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',
],
] as [string, React.ReactNode][]
).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 ?? undefined}
>
{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>
</>
);
}