'use client' import { useCallback, useState } from 'react' import Link from 'next/link' import { useFlowRun } from '@/hooks/useFlowRun' import StreamingTerminal from '@/components/StreamingTerminal' import ConfirmDialog from '@/components/ConfirmDialog' import type { BackupPhase, BackupStatus, BackupStatusEnvelope, OverallStatus, PhaseStatus, ResticSnapshot, ResticStats, } from '../_lib/types' type Props = { envelope: BackupStatusEnvelope nasSnapshots: ResticSnapshot[] b2Snapshots: ResticSnapshot[] nasStats: ResticStats | null b2Stats: ResticStats | null errors: { status?: string nasSnapshots?: string b2Snapshots?: string nasStats?: string b2Stats?: string } } type ActiveFlow = 'backup' | 'restore' | null function formatBytes(bytes: number | null | undefined): string { if (bytes == null) return '—' if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` } function formatDuration(seconds: number | null | undefined): string { if (seconds == null || seconds === 0) return '—' if (seconds < 60) return `${seconds}s` if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) return `${h}h ${m}m` } function formatTimestamp(iso: string | null | undefined): string { if (!iso) return '—' try { const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso const yyyy = d.getFullYear() const mm = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') const hh = String(d.getHours()).padStart(2, '0') const mi = String(d.getMinutes()).padStart(2, '0') return `${yyyy}-${mm}-${dd} ${hh}:${mi}` } catch { return iso } } function overallBadgeClass(status: OverallStatus): string { switch (status) { case 'success': return 'bg-green-500/15 text-green-500 border-green-500/30' case 'partial_failure': return 'bg-amber-500/15 text-amber-500 border-amber-500/30' case 'failed': return 'bg-destructive/15 text-destructive border-destructive/30' default: return 'bg-muted/50 text-muted-foreground border-border' } } function phaseIcon(status: PhaseStatus): { glyph: string; color: string } { switch (status) { case 'success': return { glyph: '✓', color: 'text-green-500' } case 'skipped': return { glyph: '–', color: 'text-muted-foreground' } case 'degraded': return { glyph: '!', color: 'text-amber-500' } case 'failed': return { glyph: '✗', color: 'text-destructive' } case 'pending': default: return { glyph: '○', color: 'text-muted-foreground/50' } } } function phaseDurationSeconds(phase: BackupPhase): number | null { if (!phase.startedAt || !phase.completedAt) return null const start = new Date(phase.startedAt).getTime() const end = new Date(phase.completedAt).getTime() if (Number.isNaN(start) || Number.isNaN(end)) return null return Math.max(0, Math.round((end - start) / 1000)) } function StatusCard({ status }: { status: BackupStatus | null }) { if (!status) { return (
No backup run recorded yet. Trigger one with the "Backup now" button below.
) } return (
{status.overallStatus.replace('_', ' ')} Last run {formatTimestamp(status.completedAt)} on{' '} {status.host || '—'}
duration {formatDuration(status.durationSeconds)}
{status.phases.map((p) => { const icon = phaseIcon(p.status) const dur = phaseDurationSeconds(p) return (
{icon.glyph}
{p.name} {p.status} {dur != null ? ` · ${formatDuration(dur)}` : ''}
) })}
) } function StatsBlock({ stats, label, error }: { stats: ResticStats | null; label: string; error?: string }) { if (error) { return (
{label}: {error}
) } if (!stats) { return (
{label}: no stats yet
) } const dedup = stats.dedupRatio != null && Number.isFinite(stats.dedupRatio) ? `${stats.dedupRatio.toFixed(2)}×` : '—' return (
{label} {stats.snapshotsCount} snapshot{stats.snapshotsCount === 1 ? '' : 's'}
restore size
{formatBytes(stats.restoreSizeBytes)}
raw data
{formatBytes(stats.rawDataBytes)}
dedup ratio
{dedup}
) } function SnapshotsTable({ snapshots, label, error, }: { snapshots: ResticSnapshot[] label: string error?: string }) { return (

{label}

{snapshots.length} shown
{error ? (
{error}
) : snapshots.length === 0 ? (
No snapshots in this repo yet.
) : (
{snapshots.map((s, i) => ( ))}
Time ID Tags Files / size added
{formatTimestamp(s.time)} {s.shortId} {s.tags.join(', ') || '—'} {s.summary?.files_new != null ? `${s.summary.files_new} new · ${formatBytes(s.summary.data_added ?? 0)}` : '—'}
)}
) } export default function ServerBackupSection({ envelope, nasSnapshots, b2Snapshots, nasStats, b2Stats, errors, }: Props) { const [pending, setPending] = useState(null) const [completedFlowRunId, setCompletedFlowRunId] = useState(null) const [activeFlow, setActiveFlow] = useState(null) const handleComplete = useCallback((flowRunId: string) => { setCompletedFlowRunId(flowRunId) }, []) const flowRun = useFlowRun(handleComplete) const startFlow = useCallback( (kind: 'backup' | 'restore') => { setPending(null) setCompletedFlowRunId(null) setActiveFlow(kind) flowRun.startFlow( kind === 'backup' ? 'server_backup_full' : 'server_backup_restore_test', false, ) }, [flowRun], ) const handleReset = useCallback(() => { flowRun.reset() setCompletedFlowRunId(null) setActiveFlow(null) }, [flowRun]) return (

Server backup (restic)

flows: server_backup_full · restore_test

Daily server-wide backup at 03:30: pg_dumpall + Forgejo dump, then restic to NAS (local) and Backblaze B2{' '} (offsite, Object Lock). Authoritative restore sources are the database dumps; live datadirs are excluded. See{' '} docs/runbooks/server-backup.md {' '} for the full procedure.

{errors.status && (
Could not read backup status: {errors.status}
)}
{flowRun.status !== 'idle' && flowRun.status !== 'running' && ( )}
{flowRun.status !== 'idle' && (
Output {activeFlow ? `(${activeFlow === 'backup' ? 'backup' : 'restore test'})` : ''} {completedFlowRunId && ( View in audit log → )}
{flowRun.status === 'done' && (

Reload this page to see the updated status, snapshots, and stats.

)}
)}
{envelope.lastRestoreTest && (

Last restore test

{envelope.lastRestoreTest.overallStatus.replace('_', ' ')}

{formatTimestamp(envelope.lastRestoreTest.completedAt)} · repo{' '} {envelope.lastRestoreTest.repo} · snapshot{' '} {envelope.lastRestoreTest.snapshotId?.slice(0, 8) ?? '—'} {' '} · {envelope.lastRestoreTest.assertions.length} assertions

{envelope.lastRestoreTest.assertions.some((a) => a.status !== 'ok') && (
    {envelope.lastRestoreTest.assertions .filter((a) => a.status !== 'ok') .map((a) => (
  • {a.status === 'missing' ? '✗ missing' : '! empty'} · {a.path}
  • ))}
)}
)} startFlow('backup')} onCancel={() => setPending(null)} /> startFlow('restore')} onCancel={() => setPending(null)} />
) }