'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 (
{status.host || '—'}
| 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)}` : '—'} |
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.
Reload this page to see the updated status, snapshots, and stats.
)}
{formatTimestamp(envelope.lastRestoreTest.completedAt)} · repo{' '}
{envelope.lastRestoreTest.repo} · snapshot{' '}
{envelope.lastRestoreTest.snapshotId?.slice(0, 8) ?? '—'}
{' '}
· {envelope.lastRestoreTest.assertions.length} assertions