Admin backups UI: list, run-now, download, delete + backup volume mounted to app

This commit is contained in:
lorentz 2026-04-23 12:31:24 +00:00
parent f3a9fb6670
commit 47fb5317a9
5 changed files with 408 additions and 1 deletions

View file

@ -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<BackupData | null>(null)
const [loading, setLoading] = useState(true)
const [triggering, setTriggering] = useState(false)
const [deletingFile, setDeletingFile] = useState<string | null>(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 (
<div className="container mx-auto py-8 max-w-5xl">
<div className="mb-6">
<h1 className="text-3xl font-bold flex items-center gap-2">
<Database className="h-7 w-7" />
Database Backups
</h1>
<p className="text-muted-foreground mt-1">
Nightly pg_dump backups, retained for 14 days.
</p>
</div>
{/* Schedule card */}
<div className="grid gap-4 md:grid-cols-3 mb-6">
<Card>
<CardContent className="pt-5 flex items-center gap-3">
<Clock className="h-5 w-5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs text-muted-foreground">Schedule</p>
<p className="font-semibold">Daily at 02:00 UTC</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 flex items-center gap-3">
<FileArchive className="h-5 w-5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs text-muted-foreground">Retention</p>
<p className="font-semibold">14 days</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 flex items-center gap-3">
<Database className="h-5 w-5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs text-muted-foreground">Backups stored</p>
<p className="font-semibold">{data ? `${data.files.length} file${data.files.length !== 1 ? 's' : ''}` : '—'}</p>
</div>
</CardContent>
</Card>
</div>
{/* Actions bar */}
<div className="flex items-center gap-3 mb-4">
<Button onClick={handleRunNow} disabled={triggering || data?.triggerPending} size="sm">
<Play className="mr-2 h-4 w-4" />
{triggering || data?.triggerPending ? 'Backup running…' : 'Run Backup Now'}
</Button>
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowLog((v) => !v)}>
<ScrollText className="mr-2 h-4 w-4" />
{showLog ? 'Hide Log' : 'Show Log'}
</Button>
{message && <p className="text-sm text-muted-foreground">{message}</p>}
{data?.triggerPending && (
<Badge variant="secondary" className="animate-pulse">Backup in progress</Badge>
)}
</div>
{/* Log viewer */}
{showLog && data?.logTail && (
<Card className="mb-4">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Backup Log (last 50 lines)</CardTitle>
</CardHeader>
<CardContent>
<pre className="text-xs bg-muted rounded p-3 overflow-auto max-h-64 whitespace-pre-wrap">
{data.logTail.join('\n') || 'No log entries yet.'}
</pre>
</CardContent>
</Card>
)}
{/* Backup files table */}
<Card>
<CardHeader>
<CardTitle className="text-base">Backup Files</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground py-4 text-center">Loading</p>
) : !data?.files.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">No backups found.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-muted-foreground">
<th className="text-left pb-2 font-medium">Filename</th>
<th className="text-left pb-2 font-medium">Created</th>
<th className="text-right pb-2 font-medium">Size</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{data.files.map((file, i) => (
<tr key={file.filename} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<td className="py-2 pr-4 font-mono text-xs">{file.filename}</td>
<td className="py-2 pr-4">{formatDate(file.createdAt)}</td>
<td className="py-2 pr-4 text-right tabular-nums">{formatBytes(file.sizeBytes)}</td>
<td className="py-2">
<div className="flex justify-end gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
title="Download"
onClick={() => handleDownload(file.filename)}
>
<Download className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
title="Delete"
disabled={deletingFile === file.filename}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this backup?</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-mono text-xs">{file.filename}</span>
<br />This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(file.filename)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
<p className="mt-4 text-xs text-muted-foreground">
To restore: <code className="bg-muted px-1 rounded">zcat horizon_YYYYMMDD.sql.gz | psql -U horizon_user -d horizon</code>
</p>
</div>
)
}

View file

@ -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 <BackupsClient />
}

View file

@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import Link from 'next/link' 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' import { CleanRestartButton } from '@/components/admin/clean-restart-button'
export default async function AdminPage() { export default async function AdminPage() {
@ -76,6 +76,14 @@ export default async function AdminPage() {
color: 'text-teal-600', color: 'text-teal-600',
bgColor: 'bg-teal-100', 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', title: 'Renewal Groups',
description: 'Configure default grouping window and renewal date rules for the client setup wizard', description: 'Configure default grouping window and renewal date rules for the client setup wizard',

View file

@ -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 })
}

View file

@ -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' })
}