feat(backup): add /settings/backups UI page with Backup now button
Server component fetches backup list via list_ops_backups agent command and parses filename/size output. Client BackupsPanel component shows a backup table and a Backup now button that triggers the backup_ops_db flow with streaming terminal output and audit log link. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4dd0490afc
commit
09050d5ce7
2 changed files with 231 additions and 0 deletions
172
app/settings/backups/_components/backups-panel.tsx
Normal file
172
app/settings/backups/_components/backups-panel.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useFlowRun } from '@/hooks/useFlowRun'
|
||||
import StreamingTerminal from '@/components/StreamingTerminal'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog'
|
||||
import type { BackupFile } from '../page'
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '—'
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
type Props = {
|
||||
backups: BackupFile[]
|
||||
listError: string | null
|
||||
}
|
||||
|
||||
export default function BackupsPanel({ backups, listError }: Props) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [completedFlowRunId, setCompletedFlowRunId] = useState<string | null>(null)
|
||||
|
||||
const handleComplete = useCallback((flowRunId: string) => {
|
||||
setCompletedFlowRunId(flowRunId)
|
||||
}, [])
|
||||
|
||||
const flowRun = useFlowRun(handleComplete)
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setPending(false)
|
||||
setCompletedFlowRunId(null)
|
||||
flowRun.startFlow('backup_ops_db', false)
|
||||
}, [flowRun])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
flowRun.reset()
|
||||
setCompletedFlowRunId(null)
|
||||
}, [flowRun])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Description */}
|
||||
<div className="rounded-lg border border-border p-5 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Backs up the <code className="font-mono text-xs">ops_dashboard</code> database using{' '}
|
||||
<code className="font-mono text-xs">pg_dump</code>. Dumps are stored in{' '}
|
||||
<code className="font-mono text-xs">/srv/ops/backups/</code> and retained for 30 days.
|
||||
For automated daily backups, enable the systemd timer:{' '}
|
||||
<code className="font-mono text-xs">deploy/ops-agent/ops-db-backup.timer</code>.
|
||||
</p>
|
||||
|
||||
<ol className="space-y-0.5">
|
||||
<li className="flex gap-2 text-xs font-mono text-muted-foreground">
|
||||
<span className="text-border min-w-[1.5rem]">1.</span>
|
||||
<span>pg_dump ops_dashboard → /srv/ops/backups/ops_db_YYYYMMDD_HHMM.dump</span>
|
||||
</li>
|
||||
<li className="flex gap-2 text-xs font-mono text-muted-foreground">
|
||||
<span className="text-border min-w-[1.5rem]">2.</span>
|
||||
<span>cleanup: delete backup files older than 30 days</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setPending(true)}
|
||||
disabled={flowRun.status === 'running'}
|
||||
className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
Backup now
|
||||
</button>
|
||||
{flowRun.status !== 'idle' && flowRun.status !== 'running' && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal output */}
|
||||
{flowRun.status !== 'idle' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Output</span>
|
||||
{completedFlowRunId && (
|
||||
<Link
|
||||
href={`/audit/${completedFlowRunId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View in audit log →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<StreamingTerminal
|
||||
lines={flowRun.lines}
|
||||
status={flowRun.status}
|
||||
error={flowRun.error}
|
||||
/>
|
||||
{flowRun.status === 'done' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reload this page to see the updated backup list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup list */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Existing backups</h2>
|
||||
|
||||
{listError && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Could not list backups: {listError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!listError && backups.length === 0 && (
|
||||
<div className="rounded-lg border border-border px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
No backups found in /srv/ops/backups/
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!listError && backups.length > 0 && (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">File</th>
|
||||
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backups.map((b, i) => (
|
||||
<tr key={b.name} className={i % 2 === 0 ? '' : 'bg-muted/10'}>
|
||||
<td className="px-4 py-2 text-muted-foreground">{b.label}</td>
|
||||
<td className="px-4 py-2">{b.name}</td>
|
||||
<td className="px-4 py-2 text-right text-muted-foreground">
|
||||
{formatSize(b.sizeBytes)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Backups older than 30 days are removed automatically by the cleanup step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
<ConfirmDialog
|
||||
open={pending}
|
||||
title="Backup ops_dashboard database"
|
||||
commandPreview={
|
||||
'flow: backup_ops_db\n\nSteps:\n 1. pg_dump ops_dashboard → /srv/ops/backups/ops_db_YYYYMMDD_HHMM.dump\n 2. cleanup: delete backups older than 30 days'
|
||||
}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setPending(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
app/settings/backups/page.tsx
Normal file
59
app/settings/backups/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { execAgent } from '@/lib/agent-client'
|
||||
import BackupsPanel from './_components/backups-panel'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export interface BackupFile {
|
||||
name: string
|
||||
sizeBytes: number
|
||||
label: string
|
||||
}
|
||||
|
||||
function parseBackupList(output: string): BackupFile[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, sizeStr] = line.split('\t')
|
||||
const sizeBytes = parseInt(sizeStr ?? '0', 10) || 0
|
||||
const m = name?.match(/ops_db_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})\.dump/)
|
||||
const label = m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}` : (name ?? '')
|
||||
return { name: name ?? '', sizeBytes, label }
|
||||
})
|
||||
.filter((b) => b.name)
|
||||
}
|
||||
|
||||
export default async function BackupsPage() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
let backups: BackupFile[] = []
|
||||
let listError: string | null = null
|
||||
|
||||
try {
|
||||
const output = await execAgent('list_ops_backups')
|
||||
backups = parseBackupList(output)
|
||||
} catch (err) {
|
||||
listError = err instanceof Error ? err.message : 'failed to list backups'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Home
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Backups</h1>
|
||||
</div>
|
||||
|
||||
<BackupsPanel backups={backups} listError={listError} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue