feat(flows): add /flows/server-backup page (full + restore-test)
Tweede entry point voor de server-backup feature die inab87c0f+20de584is opgezet. Geeft de bestaande server_backup_full en server_backup_restore_test flows een eigen plek in de /flows/-index, naast redeploy-all / update-caddy / update-scrum4me-web. Eén panel met twee knoppen ('Backup now' / 'Run restore test'); de description- en step-lijst wisselen mee met de actief gekozen flow. Bevestig- dialog en confirm-body hergebruiken de wording uit app/settings/backups/_components/server-backup-section.tsx zodat beide ingangen consistent blijven. SSE-stream via dezelfde useFlowRun hook; audit-link na afloop net als redeploy-all. De settings/backups-page (status + config) raakt niet aangepast — bewust behouden als tweede ingang voor wie al op die pagina is. Files: - app/flows/server-backup/page.tsx (new, breadcrumb + panel) - app/flows/server-backup/_components/flow-panel.tsx (new, dual-action UI) - app/flows/page.tsx (+1 entry in FLOWS array) Server-side commands.yml en /etc/ops-agent/flows/*.yml moeten nog gedeployed worden — zonder die geeft ops-agent 'flow_key not found' terug. Deployment-script komt los. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ec7c5a616a
commit
f01bd555d1
3 changed files with 214 additions and 0 deletions
|
|
@ -20,6 +20,11 @@ const FLOWS = [
|
|||
title: 'Update Caddy config',
|
||||
desc: 'Reload Caddy met nieuwe Caddyfile + cert renewal check',
|
||||
},
|
||||
{
|
||||
href: '/flows/server-backup',
|
||||
title: 'Server backup',
|
||||
desc: 'pg_dumpall + restic naar NAS én B2 — handmatige backup of restore-test',
|
||||
},
|
||||
]
|
||||
|
||||
export default async function FlowsIndex() {
|
||||
|
|
|
|||
178
app/flows/server-backup/_components/flow-panel.tsx
Normal file
178
app/flows/server-backup/_components/flow-panel.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
'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'
|
||||
|
||||
// One panel runs either flow; we switch step-list + description based on the
|
||||
// currently active or last-triggered action. Wording mirrors the existing
|
||||
// /settings/backups → server-backup-section so the two entry points stay
|
||||
// consistent. The actual work in both flows runs out-of-band via systemd
|
||||
// (server-backup.service) — the ops-agent flow just kicks it off and tails
|
||||
// the resulting log / status file.
|
||||
|
||||
type Kind = 'backup' | 'restore'
|
||||
|
||||
type FlowSpec = {
|
||||
flowKey: string
|
||||
buttonLabel: string
|
||||
shortDescription: string
|
||||
steps: string[]
|
||||
confirmTitle: string
|
||||
confirmBody: string
|
||||
}
|
||||
|
||||
const FLOWS: Record<Kind, FlowSpec> = {
|
||||
backup: {
|
||||
flowKey: 'server_backup_full',
|
||||
buttonLabel: 'Backup now',
|
||||
shortDescription:
|
||||
'Volledige server-backup: pg_dumpall van alle databases + restic snapshot naar NAS én Backblaze B2 (Object Lock). Draait dagelijks via timer; deze knop triggert handmatig.',
|
||||
steps: [
|
||||
'trigger_server_backup (systemctl start server-backup.service)',
|
||||
'tail_backup_log_today (live log mee-stream)',
|
||||
'read_backup_status (lees status.json met repo-totalen + duur)',
|
||||
],
|
||||
confirmTitle: 'Trigger server backup',
|
||||
confirmBody:
|
||||
'flow: server_backup_full\n\nSteps:\n 1. trigger_server_backup (systemctl start server-backup.service)\n 2. tail_backup_log_today\n 3. read_backup_status\n\nThe actual work happens in systemd; this flow kicks it off and tails the log.',
|
||||
},
|
||||
restore: {
|
||||
flowKey: 'server_backup_restore_test',
|
||||
buttonLabel: 'Run restore test',
|
||||
shortDescription:
|
||||
'Non-destructieve restore-test: haalt de laatste snapshot uit de NAS-repo terug naar /tmp/restore-test en verifieert dat kritieke files er zijn. Raakt niets in de live stack.',
|
||||
steps: [
|
||||
'trigger_restore_test (restore latest NAS snapshot to /tmp/restore-test/)',
|
||||
'read_backup_status (lees assertions + per-file outcome)',
|
||||
],
|
||||
confirmTitle: 'Run restore test (NAS)',
|
||||
confirmBody:
|
||||
'flow: server_backup_restore_test\n\nSteps:\n 1. trigger_restore_test (restore latest NAS snapshot to /tmp/restore-test/)\n 2. read_backup_status\n\nNon-destructive — restores into /tmp only and asserts critical files exist.',
|
||||
},
|
||||
}
|
||||
|
||||
export default function FlowPanel() {
|
||||
// `displayKind` drives the steps/description card; updated optimistically
|
||||
// when the user presses a button so the displayed flow matches the pending
|
||||
// confirm. `activeKind` only flips once the flow actually starts.
|
||||
const [displayKind, setDisplayKind] = useState<Kind>('backup')
|
||||
const [pendingKind, setPendingKind] = useState<Kind | null>(null)
|
||||
const [activeKind, setActiveKind] = useState<Kind | null>(null)
|
||||
const [completedFlowRunId, setCompletedFlowRunId] = useState<string | null>(null)
|
||||
|
||||
const handleComplete = useCallback((flowRunId: string) => {
|
||||
setCompletedFlowRunId(flowRunId)
|
||||
}, [])
|
||||
|
||||
const flowRun = useFlowRun(handleComplete)
|
||||
|
||||
const handleClickKind = useCallback((kind: Kind) => {
|
||||
setDisplayKind(kind)
|
||||
setPendingKind(kind)
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (pendingKind === null) return
|
||||
const kind = pendingKind
|
||||
setPendingKind(null)
|
||||
setCompletedFlowRunId(null)
|
||||
setActiveKind(kind)
|
||||
flowRun.startFlow(FLOWS[kind].flowKey, false)
|
||||
}, [pendingKind, flowRun])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
flowRun.reset()
|
||||
setCompletedFlowRunId(null)
|
||||
setActiveKind(null)
|
||||
}, [flowRun])
|
||||
|
||||
const spec = FLOWS[displayKind]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-border p-5 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{spec.shortDescription}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground font-mono">
|
||||
flow: {spec.flowKey}
|
||||
</p>
|
||||
</div>
|
||||
<ol className="space-y-1">
|
||||
{spec.steps.map((step, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs font-mono text-muted-foreground">
|
||||
<span className="text-border min-w-[1.5rem]">{i + 1}.</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleClickKind('backup')}
|
||||
disabled={flowRun.status === 'running'}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-opacity disabled:opacity-50 ${
|
||||
displayKind === 'backup'
|
||||
? 'bg-foreground text-background hover:opacity-90'
|
||||
: 'border border-border hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
{FLOWS.backup.buttonLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClickKind('restore')}
|
||||
disabled={flowRun.status === 'running'}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-opacity disabled:opacity-50 ${
|
||||
displayKind === 'restore'
|
||||
? 'bg-foreground text-background hover:opacity-90'
|
||||
: 'border border-border hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
{FLOWS.restore.buttonLabel}
|
||||
</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{activeKind ? ` — ${FLOWS[activeKind].buttonLabel}` : ''}
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingKind !== null}
|
||||
title={pendingKind ? FLOWS[pendingKind].confirmTitle : ''}
|
||||
commandPreview={pendingKind ? FLOWS[pendingKind].confirmBody : ''}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setPendingKind(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
app/flows/server-backup/page.tsx
Normal file
31
app/flows/server-backup/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import FlowPanel from './_components/flow-panel'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ServerBackupPage() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
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>
|
||||
<Link href="/flows" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
Flows
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Server backup</h1>
|
||||
</div>
|
||||
|
||||
<FlowPanel />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue