feat(flows): add update_caddy_config flow with validate, reload/force-restart, and smoke test

- Update flows.example/update_caddy_config.yml with caddy_validate → caddy_reload → smoke test steps and hostname comments
- Add flows.example/update_caddy_config_force.yml for docker compose hard restart variant
- Add /flows/update-caddy-config UI page with reload/force-restart toggle, dry-run mode showing pending Caddyfile preview, hostname detection, and audit log link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-13 19:54:03 +02:00
parent 6bee8e8741
commit 1e31e3b584
4 changed files with 353 additions and 7 deletions

View file

@ -0,0 +1,262 @@
'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'
type RestartMode = 'reload' | 'force'
const FLOW_KEYS: Record<RestartMode, string> = {
reload: 'update_caddy_config',
force: 'update_caddy_config_force',
}
const STEP_LABELS: Record<RestartMode, string[]> = {
reload: [
'caddy validate (syntax check)',
'caddy reload (zero-downtime via admin API)',
'smoke test: curl -I each hostname (expect 200/301/308/401)',
],
force: [
'caddy validate (syntax check)',
'docker compose up --force-recreate caddy (hard restart)',
'smoke test: curl -I each hostname (expect 200/301/308/401)',
],
}
function parseCaddyfileHostnames(content: string): string[] {
const hostnames: string[] = []
// Match top-level server block labels: hostname { or hostname:port {
for (const match of content.matchAll(/^([a-zA-Z0-9][a-zA-Z0-9\-.:]+)\s*\{/gm)) {
const candidate = match[1].trim()
// Skip obvious non-hostnames like "tls", "log", "handle", etc.
if (!candidate.includes('.') && !candidate.includes(':')) continue
// Strip port suffix for display
const host = candidate.split(':')[0]
if (!hostnames.includes(host)) hostnames.push(host)
}
return hostnames
}
function CaddyfileDiff({ content, error }: { content: string; error: string | null }) {
if (error) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
Could not load Caddyfile: {error}
</div>
)
}
if (!content.trim()) {
return (
<div className="rounded-lg border border-border px-4 py-6 text-sm text-muted-foreground text-center">
Caddyfile is empty or not found
</div>
)
}
return (
<pre className="overflow-x-auto rounded-lg border border-border bg-zinc-950 p-4 text-xs font-mono text-zinc-100 leading-relaxed max-h-80">
{content}
</pre>
)
}
type Props = {
caddyfile: string
caddyfileError: string | null
}
export default function FlowPanel({ caddyfile, caddyfileError }: Props) {
const [mode, setMode] = useState<RestartMode>('reload')
const [pendingDryRun, setPendingDryRun] = useState<boolean | null>(null)
const [completedFlowRunId, setCompletedFlowRunId] = useState<string | null>(null)
const [showConfig, setShowConfig] = useState(false)
const handleComplete = useCallback((flowRunId: string) => {
setCompletedFlowRunId(flowRunId)
}, [])
const flowRun = useFlowRun(handleComplete)
const handleConfirm = useCallback(() => {
if (pendingDryRun === null) return
const dryRun = pendingDryRun
setPendingDryRun(null)
setCompletedFlowRunId(null)
flowRun.startFlow(FLOW_KEYS[mode], dryRun)
}, [pendingDryRun, flowRun, mode])
const handleReset = useCallback(() => {
flowRun.reset()
setCompletedFlowRunId(null)
}, [flowRun])
const hostnames = parseCaddyfileHostnames(caddyfile)
const steps = STEP_LABELS[mode]
const flowKey = FLOW_KEYS[mode]
return (
<div className="space-y-6">
{/* Description */}
<div className="rounded-lg border border-border p-5 space-y-4">
<div>
<p className="text-sm text-muted-foreground">
Validates the Caddyfile on disk, then reloads or restarts Caddy, and
smoke-tests every public hostname. Edit the config first via the{' '}
<Link href="/caddy/edit" className="underline hover:text-foreground transition-colors">
Caddy editor
</Link>
.
</p>
<p className="mt-1 text-xs text-muted-foreground font-mono">
config: /srv/scrum4me/caddy/Caddyfile
</p>
</div>
{/* Restart mode toggle */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Restart mode:</span>
<button
onClick={() => setMode('reload')}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
mode === 'reload'
? 'bg-foreground text-background'
: 'border border-border hover:bg-muted/50'
}`}
>
Reload (zero-downtime)
</button>
<button
onClick={() => setMode('force')}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
mode === 'force'
? 'bg-foreground text-background'
: 'border border-border hover:bg-muted/50'
}`}
>
Force Restart (docker compose)
</button>
</div>
{/* Step list */}
<ol className="space-y-1">
{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>
{/* Detected hostnames */}
{hostnames.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
Hostnames detected in Caddyfile (will be smoke-tested):
</p>
<ul className="space-y-0.5">
{hostnames.map((h) => (
<li key={h} className="text-xs font-mono text-muted-foreground pl-4">
{h}
</li>
))}
</ul>
</div>
)}
</div>
{/* Config preview (dry-run diff) */}
<div className="space-y-2">
<button
onClick={() => setShowConfig((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showConfig ? '▾' : '▸'} {showConfig ? 'Hide' : 'Preview'} pending Caddyfile
</button>
{showConfig && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
This is the config that will be validated and applied:
</p>
<CaddyfileDiff content={caddyfile} error={caddyfileError} />
{!caddyfileError && (
<p className="text-xs text-muted-foreground">
To change the config before applying,{' '}
<Link href="/caddy/edit" className="underline hover:text-foreground">
edit in the Caddy editor
</Link>{' '}
first.
</p>
)}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<button
onClick={() => setPendingDryRun(false)}
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"
>
Run
</button>
<button
onClick={() => setPendingDryRun(true)}
disabled={flowRun.status === 'running'}
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Dry Run
</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}
/>
</div>
)}
{/* Confirm dialog */}
<ConfirmDialog
open={pendingDryRun !== null}
title={
pendingDryRun
? `Dry Run: Update Caddy Config (${mode === 'reload' ? 'Reload' : 'Force Restart'})`
: `Run: Update Caddy Config (${mode === 'reload' ? 'Reload' : 'Force Restart'})`
}
commandPreview={
pendingDryRun
? `[DRY RUN] flow: ${flowKey}\n\nAll steps will be shown without executing.\n\nPending config: /srv/scrum4me/caddy/Caddyfile${hostnames.length > 0 ? `\nHostnames: ${hostnames.join(', ')}` : ''}`
: `flow: ${flowKey}\n\nSteps:\n${steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}${hostnames.length > 0 ? `\n\nHostnames to smoke-test: ${hostnames.join(', ')}` : ''}`
}
onConfirm={handleConfirm}
onCancel={() => setPendingDryRun(null)}
/>
</div>
)
}