'use client'; import { useEffect, useState } from 'react'; import { Loader2, Plus, Server, Cloud, FolderTree, Trash2, Pencil, PlugZap, UploadCloud, ShieldCheck, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; type DestType = 'sftp' | 's3' | 'filesystem'; type Schedule = 'off' | 'daily' | 'weekly'; interface Destination { id: string; name: string; type: DestType; enabled: boolean; config: Record; retentionCount: number | null; encryptBundle: boolean; encryptionKeyIsSet: boolean; lastRunAt: string | null; lastStatus: string | null; lastError: string | null; lastBackupBytes: number | null; } const TYPE_META: Record = { sftp: { label: 'SFTP / SSH server', icon: Server, hint: 'Push to a server over SFTP.' }, s3: { label: 'S3-compatible', icon: Cloud, hint: 'AWS S3, Backblaze B2, Wasabi, R2, MinIO.' }, filesystem: { label: 'Mounted path / NAS', icon: FolderTree, hint: 'A directory this server can write to.', }, }; const STATUS_TONE: Record = { ok: 'text-emerald-700', failed: 'text-rose-700', }; function formatBytes(n: number | null): string { if (n === null) return '—'; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; } export function BackupDestinationsCard() { const [destinations, setDestinations] = useState([]); const [schedule, setSchedule] = useState('off'); const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [editing, setEditing] = useState(null); useEffect(() => { void load(); }, []); async function load() { setLoading(true); try { const [d, s] = await Promise.all([ apiFetch<{ data: Destination[] }>('/api/v1/admin/backup/destinations'), apiFetch<{ data: { schedule: Schedule } }>('/api/v1/admin/backup/schedule'), ]); setDestinations(d.data); setSchedule(s.data.schedule); } catch (err) { toastError(err); } finally { setLoading(false); } } async function changeSchedule(value: Schedule) { setSchedule(value); try { await apiFetch('/api/v1/admin/backup/schedule', { method: 'PUT', body: { schedule: value }, }); toast.success( value === 'off' ? 'Automated backups turned off' : `Automated backups: ${value}`, ); } catch (err) { toastError(err); void load(); } } async function test(id: string) { setBusyId(id); try { await apiFetch(`/api/v1/admin/backup/destinations/${id}/test`, { method: 'POST' }); toast.success('Connection OK'); } catch (err) { toastError(err); } finally { setBusyId(null); } } async function runNow(id: string) { setBusyId(id); try { toast.info('Backing up — assembling the bundle, then pushing. This can take a minute.'); await apiFetch(`/api/v1/admin/backup/destinations/${id}/run`, { method: 'POST' }); toast.success('Backup pushed'); await load(); } catch (err) { toastError(err); await load(); } finally { setBusyId(null); } } async function remove(id: string) { if (!confirm('Delete this backup destination? This does not delete already-pushed backups.')) return; setBusyId(id); try { await apiFetch(`/api/v1/admin/backup/destinations/${id}`, { method: 'DELETE' }); toast.success('Destination deleted'); await load(); } catch (err) { toastError(err); } finally { setBusyId(null); } } return (
Automated backup destinations Where scheduled backups are pushed. Each destination receives the same full bundle (database + every file) you can download above.
Pushes to every enabled destination below.
{loading ? (
Loading…
) : destinations.length === 0 ? (

No destinations yet. Add one to enable automated offsite backups.

) : (
    {destinations.map((d) => { const Icon = TYPE_META[d.type].icon; return (
  • {d.name} {d.encryptBundle && ( )} {!d.enabled && ( disabled )}
    {TYPE_META[d.type].label} {d.lastStatus && ( <> {' · '} {d.lastStatus === 'ok' ? 'last OK' : 'last FAILED'} {d.lastRunAt && ` ${new Date(d.lastRunAt).toLocaleString()}`} {d.lastStatus === 'ok' && ` (${formatBytes(d.lastBackupBytes)})`} )} {d.lastStatus === 'failed' && d.lastError && ( {d.lastError} )}
  • ); })}
)}
{editing && ( setEditing(null)} onSaved={() => { setEditing(null); void load(); }} /> )}
); } // ─── add/edit dialog ───────────────────────────────────────────────────────── interface DialogProps { destination: Destination | null; onClose: () => void; onSaved: () => void; } function DestinationDialog({ destination, onClose, onSaved }: DialogProps) { const isEdit = Boolean(destination); const cfg = (destination?.config ?? {}) as Record; const str = (k: string) => (typeof cfg[k] === 'string' ? (cfg[k] as string) : ''); const num = (k: string) => (typeof cfg[k] === 'number' ? String(cfg[k]) : ''); const [name, setName] = useState(destination?.name ?? ''); const [type, setType] = useState(destination?.type ?? 'sftp'); const [enabled, setEnabled] = useState(destination?.enabled ?? true); const [retention, setRetention] = useState( destination?.retentionCount != null ? String(destination.retentionCount) : '', ); const [encryptBundle, setEncryptBundle] = useState(destination?.encryptBundle ?? false); const [encryptionKey, setEncryptionKey] = useState(''); const [saving, setSaving] = useState(false); // Config fields (controlled). Secrets start blank on edit (kept server-side). const [c, setC] = useState>({ directory: str('directory'), host: str('host'), port: num('port'), username: str('username'), password: '', privateKey: '', passphrase: '', remoteDir: str('remoteDir'), hostFingerprint: str('hostFingerprint'), endpoint: str('endpoint'), region: str('region'), bucket: str('bucket'), accessKey: str('accessKey'), secretKey: '', prefix: str('prefix'), }); const set = (k: string, v: string) => setC((prev) => ({ ...prev, [k]: v })); function buildConfig(): Record { if (type === 'filesystem') return { directory: c.directory }; if (type === 'sftp') { return { host: c.host, ...(c.port ? { port: Number(c.port) } : {}), username: c.username, ...(c.password ? { password: c.password } : {}), ...(c.privateKey ? { privateKey: c.privateKey } : {}), ...(c.passphrase ? { passphrase: c.passphrase } : {}), remoteDir: c.remoteDir, ...(c.hostFingerprint ? { hostFingerprint: c.hostFingerprint } : {}), }; } return { endpoint: c.endpoint, ...(c.region ? { region: c.region } : {}), bucket: c.bucket, accessKey: c.accessKey, ...(c.secretKey ? { secretKey: c.secretKey } : {}), ...(c.prefix ? { prefix: c.prefix } : {}), }; } async function save() { setSaving(true); try { const body = { name, type, enabled, config: buildConfig(), retentionCount: retention ? Number(retention) : null, encryptBundle, ...(encryptionKey ? { encryptionKey } : {}), }; if (isEdit && destination) { await apiFetch(`/api/v1/admin/backup/destinations/${destination.id}`, { method: 'PUT', body, }); } else { await apiFetch('/api/v1/admin/backup/destinations', { method: 'POST', body, }); } toast.success(isEdit ? 'Destination updated' : 'Destination added'); onSaved(); } catch (err) { toastError(err); } finally { setSaving(false); } } const secretPlaceholder = isEdit ? 'unchanged — leave blank to keep' : ''; return ( !o && onClose()}> {isEdit ? 'Edit destination' : 'Add backup destination'} {TYPE_META[type].hint}
setName(e.target.value)} placeholder="Hetzner box" /> {type === 'filesystem' && ( set('directory', e.target.value)} placeholder="/mnt/backups" /> )} {type === 'sftp' && ( <>
set('host', e.target.value)} placeholder="backups.example.com" />
set('port', e.target.value)} placeholder="22" />
set('username', e.target.value)} /> set('password', e.target.value)} placeholder={secretPlaceholder} />