Ops-dashboard/app/settings/backups/_components/backups-panel.tsx
Scrum4Me Agent 09050d5ce7 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>
2026-05-13 20:07:34 +02:00

172 lines
6.2 KiB
TypeScript

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