refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
const DEFAULT_SETTINGS = {
|
|
|
|
|
enabled: false,
|
|
|
|
|
frequency: 'daily',
|
|
|
|
|
time: '02:00',
|
2026-05-30 21:20:51 -05:00
|
|
|
retention_count: 2,
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
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,
|
2026-05-30 21:20:51 -05:00
|
|
|
retention_count: parseInt(settings.retention_count, 10) || 2,
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
});
|
|
|
|
|
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>
|
|
|
|
|
<Badge className={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>
|
|
|
|
|
</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">
|
|
|
|
|
<Label>Keep scheduled</Label>
|
|
|
|
|
<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>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|