Adds a server-wide backup capability beyond the existing ops_dashboard pg_dump flow: - Daily systemd timer (03:30) runs pg_dumpall + Forgejo dump, then restic to a local NAS repo and an offsite Backblaze B2 repo with Object Lock. Phase-based script with single-instance flock, structured statusfile, systemd hardening, and live-datadir excludes (Postgres / Forgejo) so the dumps stay authoritative. - Ops-agent gets nine new read-only/trigger commands (snapshots, stats, status, logs, plus two triggers) backed by sudoers-whitelisted wrapper scripts that source /etc/restic-backup.env so the agent never sees the restic password or B2 keys. - Two new flows (server_backup_full, server_backup_restore_test) drive the dashboard's "Backup now" and "Restore test" buttons. - /settings/backups gains a Server backup section with overall + per-phase status, NAS / B2 snapshot tables, restore-size / raw-data / dedup-ratio stats, and the last restore-test result. The existing pg_dump section is preserved unchanged. - Runbook docs/runbooks/server-backup.md follows the tailscale-setup pattern (plan + addendum) and covers B2 Object Lock + scoped keys, Forgejo subplan with isolated restore-test stack, the off-server maintenance flow for B2 prune, and the integrity-check schedule. Code-only change — installation on scrum4me-srv follows the runbook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
6.3 KiB
TypeScript
172 lines
6.3 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useState } 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 DatabaseBackupsSection({ 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 (
|
|
<section className="space-y-6">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 className="text-lg font-semibold tracking-tight">Database backups</h2>
|
|
<span className="text-xs text-muted-foreground">flow: backup_ops_db</span>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-semibold">Existing backups</h3>
|
|
|
|
{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>
|
|
|
|
<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)}
|
|
/>
|
|
</section>
|
|
)
|
|
}
|