From 47fb5317a9b48cd4ad5c2270d2470967ce975c9f Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 23 Apr 2026 12:31:24 +0000 Subject: [PATCH] Admin backups UI: list, run-now, download, delete + backup volume mounted to app --- .../(dashboard)/admin/backups/page-client.tsx | 254 ++++++++++++++++++ .../app/(dashboard)/admin/backups/page.tsx | 13 + ondeck/src/app/(dashboard)/admin/page.tsx | 10 +- .../app/api/admin/backups/[filename]/route.ts | 62 +++++ ondeck/src/app/api/admin/backups/route.ts | 70 +++++ 5 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 ondeck/src/app/(dashboard)/admin/backups/page-client.tsx create mode 100644 ondeck/src/app/(dashboard)/admin/backups/page.tsx create mode 100644 ondeck/src/app/api/admin/backups/[filename]/route.ts create mode 100644 ondeck/src/app/api/admin/backups/route.ts diff --git a/ondeck/src/app/(dashboard)/admin/backups/page-client.tsx b/ondeck/src/app/(dashboard)/admin/backups/page-client.tsx new file mode 100644 index 0000000..f1020cd --- /dev/null +++ b/ondeck/src/app/(dashboard)/admin/backups/page-client.tsx @@ -0,0 +1,254 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Download, Trash2, Play, RefreshCw, Database, Clock, FileArchive, ScrollText } from 'lucide-react' + +interface BackupFile { + filename: string + sizeBytes: number + createdAt: string +} + +interface BackupData { + files: BackupFile[] + logTail: string[] + triggerPending: boolean +} + +function formatBytes(bytes: number) { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', timeZoneName: 'short', + }) +} + +export function BackupsClient() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [triggering, setTriggering] = useState(false) + const [deletingFile, setDeletingFile] = useState(null) + const [message, setMessage] = useState('') + const [showLog, setShowLog] = useState(false) + + const fetchData = useCallback(async () => { + try { + const res = await fetch('/api/admin/backups') + if (res.ok) setData(await res.json()) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + // Auto-refresh while trigger is pending + useEffect(() => { + if (!data?.triggerPending) return + const interval = setInterval(fetchData, 5000) + return () => clearInterval(interval) + }, [data?.triggerPending, fetchData]) + + const handleRunNow = async () => { + setTriggering(true) + setMessage('') + const res = await fetch('/api/admin/backups', { method: 'POST' }) + const json = await res.json() + setMessage(json.message || json.error || '') + setTriggering(false) + await fetchData() + } + + const handleDelete = async (filename: string) => { + setDeletingFile(filename) + await fetch(`/api/admin/backups/${filename}`, { method: 'DELETE' }) + setDeletingFile(null) + await fetchData() + } + + const handleDownload = (filename: string) => { + window.location.href = `/api/admin/backups/${filename}` + } + + return ( +
+
+

+ + Database Backups +

+

+ Nightly pg_dump backups, retained for 14 days. +

+
+ + {/* Schedule card */} +
+ + + +
+

Schedule

+

Daily at 02:00 UTC

+
+
+
+ + + +
+

Retention

+

14 days

+
+
+
+ + + +
+

Backups stored

+

{data ? `${data.files.length} file${data.files.length !== 1 ? 's' : ''}` : '—'}

+
+
+
+
+ + {/* Actions bar */} +
+ + + + {message &&

{message}

} + {data?.triggerPending && ( + Backup in progress… + )} +
+ + {/* Log viewer */} + {showLog && data?.logTail && ( + + + Backup Log (last 50 lines) + + +
+              {data.logTail.join('\n') || 'No log entries yet.'}
+            
+
+
+ )} + + {/* Backup files table */} + + + Backup Files + + + {loading ? ( +

Loading…

+ ) : !data?.files.length ? ( +

No backups found.

+ ) : ( + + + + + + + + + + {data.files.map((file, i) => ( + + + + + + + ))} + +
FilenameCreatedSize +
{file.filename}{formatDate(file.createdAt)}{formatBytes(file.sizeBytes)} +
+ + + + + + + + + Delete this backup? + + {file.filename} +
This cannot be undone. +
+
+ + Cancel + handleDelete(file.filename)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + +
+
+
+
+ )} +
+
+ +

+ To restore: zcat horizon_YYYYMMDD.sql.gz | psql -U horizon_user -d horizon +

+
+ ) +} diff --git a/ondeck/src/app/(dashboard)/admin/backups/page.tsx b/ondeck/src/app/(dashboard)/admin/backups/page.tsx new file mode 100644 index 0000000..b4efbc6 --- /dev/null +++ b/ondeck/src/app/(dashboard)/admin/backups/page.tsx @@ -0,0 +1,13 @@ +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { BackupsClient } from './page-client' + +export default async function BackupsPage() { + const session = await getServerSession(authOptions) + if (!session?.user) redirect('/auth/signin') + const roles = (session.user as any)?.roles || [] + if (!roles.includes('Admin')) redirect('/dashboard') + + return +} diff --git a/ondeck/src/app/(dashboard)/admin/page.tsx b/ondeck/src/app/(dashboard)/admin/page.tsx index 3fb2412..989fa3f 100644 --- a/ondeck/src/app/(dashboard)/admin/page.tsx +++ b/ondeck/src/app/(dashboard)/admin/page.tsx @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import Link from 'next/link' -import { Shapes, FileText, Users, Database, Settings, ClipboardList, History, CalendarRange } from 'lucide-react' +import { Shapes, FileText, Users, Database, Settings, ClipboardList, History, CalendarRange, HardDrive } from 'lucide-react' import { CleanRestartButton } from '@/components/admin/clean-restart-button' export default async function AdminPage() { @@ -76,6 +76,14 @@ export default async function AdminPage() { color: 'text-teal-600', bgColor: 'bg-teal-100', }, + { + title: 'Backups', + description: 'View nightly database backups, download or trigger an on-demand backup', + icon: HardDrive, + href: '/admin/backups', + color: 'text-cyan-600', + bgColor: 'bg-cyan-100', + }, { title: 'Renewal Groups', description: 'Configure default grouping window and renewal date rules for the client setup wizard', diff --git a/ondeck/src/app/api/admin/backups/[filename]/route.ts b/ondeck/src/app/api/admin/backups/[filename]/route.ts new file mode 100644 index 0000000..0cd144e --- /dev/null +++ b/ondeck/src/app/api/admin/backups/[filename]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import fs from 'fs' +import path from 'path' + +const BACKUP_DIR = '/backups' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ filename: string }> } +) { + const session = await getServerSession(authOptions) + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const roles = (session.user as any).roles || [] + if (!roles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const { filename } = await params + + // Strict validation — only allow our backup file pattern + if (!/^horizon_\d{8}_\d{6}\.sql\.gz$/.test(filename)) { + return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }) + } + + const filePath = path.join(BACKUP_DIR, filename) + if (!fs.existsSync(filePath)) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + const fileBuffer = fs.readFileSync(filePath) + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': 'application/gzip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': fileBuffer.length.toString(), + }, + }) +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ filename: string }> } +) { + const session = await getServerSession(authOptions) + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const roles = (session.user as any).roles || [] + if (!roles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const { filename } = await params + + if (!/^horizon_\d{8}_\d{6}\.sql\.gz$/.test(filename)) { + return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }) + } + + const filePath = path.join(BACKUP_DIR, filename) + if (!fs.existsSync(filePath)) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + fs.unlinkSync(filePath) + return NextResponse.json({ ok: true }) +} diff --git a/ondeck/src/app/api/admin/backups/route.ts b/ondeck/src/app/api/admin/backups/route.ts new file mode 100644 index 0000000..bc748b0 --- /dev/null +++ b/ondeck/src/app/api/admin/backups/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import fs from 'fs' +import path from 'path' + +const BACKUP_DIR = '/backups' +const TRIGGER_FILE = path.join(BACKUP_DIR, '.trigger') +const LOG_FILE = path.join(BACKUP_DIR, 'backup.log') + +function requireAdmin() { + return getServerSession(authOptions).then((session) => { + if (!session?.user) return null + const roles = (session.user as any).roles || [] + return roles.includes('Admin') ? session : null + }) +} + +function parseBackupFiles() { + if (!fs.existsSync(BACKUP_DIR)) return [] + return fs + .readdirSync(BACKUP_DIR) + .filter((f) => f.match(/^horizon_\d{8}_\d{6}\.sql\.gz$/)) + .map((filename) => { + const stat = fs.statSync(path.join(BACKUP_DIR, filename)) + const match = filename.match(/^horizon_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})\.sql\.gz$/) + const createdAt = match + ? new Date( + `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z` + ).toISOString() + : stat.mtime.toISOString() + return { + filename, + sizeBytes: stat.size, + createdAt, + } + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) +} + +/** GET /api/admin/backups — list backups + log tail */ +export async function GET(request: NextRequest) { + const session = await requireAdmin() + if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const files = parseBackupFiles() + + let logTail: string[] = [] + if (fs.existsSync(LOG_FILE)) { + const content = fs.readFileSync(LOG_FILE, 'utf-8') + logTail = content.trim().split('\n').slice(-50) + } + + const triggerPending = fs.existsSync(TRIGGER_FILE) + + return NextResponse.json({ files, logTail, triggerPending }) +} + +/** POST /api/admin/backups — trigger an on-demand backup */ +export async function POST(request: NextRequest) { + const session = await requireAdmin() + if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + if (!fs.existsSync(BACKUP_DIR)) { + return NextResponse.json({ error: 'Backup directory not mounted' }, { status: 500 }) + } + + fs.writeFileSync(TRIGGER_FILE, new Date().toISOString()) + return NextResponse.json({ ok: true, message: 'Backup triggered — check back in ~30 seconds' }) +}