Admin backups UI: list, run-now, download, delete + backup volume mounted to app
This commit is contained in:
parent
f3a9fb6670
commit
47fb5317a9
5 changed files with 408 additions and 1 deletions
254
ondeck/src/app/(dashboard)/admin/backups/page-client.tsx
Normal file
254
ondeck/src/app/(dashboard)/admin/backups/page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
ondeck/src/app/(dashboard)/admin/backups/page.tsx
Normal file
13
ondeck/src/app/(dashboard)/admin/backups/page.tsx
Normal 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 />
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
62
ondeck/src/app/api/admin/backups/[filename]/route.ts
Normal file
62
ondeck/src/app/api/admin/backups/[filename]/route.ts
Normal 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 })
|
||||
}
|
||||
70
ondeck/src/app/api/admin/backups/route.ts
Normal file
70
ondeck/src/app/api/admin/backups/route.ts
Normal 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' })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue