- 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>
262 lines
9.1 KiB
TypeScript
262 lines
9.1 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'
|
|
|
|
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>
|
|
)
|
|
}
|