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 { 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',
|
||||||
|
|
|
||||||
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