diff --git a/app/settings/backups/_components/backups-panel.tsx b/app/settings/backups/_components/backups-panel.tsx new file mode 100644 index 0000000..b2707d9 --- /dev/null +++ b/app/settings/backups/_components/backups-panel.tsx @@ -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(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 ( +
+ {/* Description */} +
+

+ Backs up the ops_dashboard database using{' '} + pg_dump. Dumps are stored in{' '} + /srv/ops/backups/ and retained for 30 days. + For automated daily backups, enable the systemd timer:{' '} + deploy/ops-agent/ops-db-backup.timer. +

+ +
    +
  1. + 1. + pg_dump ops_dashboard → /srv/ops/backups/ops_db_YYYYMMDD_HHMM.dump +
  2. +
  3. + 2. + cleanup: delete backup files older than 30 days +
  4. +
+
+ + {/* Action buttons */} +
+ + {flowRun.status !== 'idle' && flowRun.status !== 'running' && ( + + )} +
+ + {/* Terminal output */} + {flowRun.status !== 'idle' && ( +
+
+ Output + {completedFlowRunId && ( + + View in audit log → + + )} +
+ + {flowRun.status === 'done' && ( +

+ Reload this page to see the updated backup list. +

+ )} +
+ )} + + {/* Backup list */} +
+

Existing backups

+ + {listError && ( +
+ Could not list backups: {listError} +
+ )} + + {!listError && backups.length === 0 && ( +
+ No backups found in /srv/ops/backups/ +
+ )} + + {!listError && backups.length > 0 && ( +
+ + + + + + + + + + {backups.map((b, i) => ( + + + + + + ))} + +
+ Timestamp + FileSize
{b.label}{b.name} + {formatSize(b.sizeBytes)} +
+
+ )} + +

+ Backups older than 30 days are removed automatically by the cleanup step. +

+
+ + {/* Confirm dialog */} + setPending(false)} + /> +
+ ) +} diff --git a/app/settings/backups/page.tsx b/app/settings/backups/page.tsx new file mode 100644 index 0000000..89f72c7 --- /dev/null +++ b/app/settings/backups/page.tsx @@ -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 ( +
+
+
+ + ← Home + + / +

Backups

+
+ + +
+
+ ) +}