- ops-agent/src/lib/flow-runner.ts: loads YAML flows, validates all steps
against the command whitelist, executes sequentially; supports dry_run
(emits WOULD RUN lines) and on_failure: abort|continue per step
- ops-agent/src/routes/flow.ts: POST /agent/v1/flow { flow_key, dry_run }
streams step_start/stdout/stderr/step_done/done SSE events
- ops-agent/src/index.ts: register flow route, add FLOWS_PATH env var
- ops-agent/flows.example/: three flow definitions — update_scrum4me_web,
update_mcp_worker, update_caddy_config; deploy to /etc/ops-agent/flows/
- ops-agent/commands.yml.example: add curl_smoke_scrum4me_web and
docker_compose_ps_worker smoke-test commands
- app/api/flows/run/route.ts: Next.js proxy — creates FlowRun/FlowStep
DB records per step, forwards SSE stream to browser
- hooks/useFlowRun.ts: add startFlow(flowKey, dryRun) method; handle
step_start events to display step headers in the terminal
- components/StreamingTerminal.tsx: add 'info' line type (sky-400) for
step headers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
93 lines
2.6 KiB
TypeScript
93 lines
2.6 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
|
|
export type TerminalLine = {
|
|
type: 'stdout' | 'stderr' | 'info'
|
|
text: string
|
|
}
|
|
|
|
export type TerminalStatus = 'idle' | 'running' | 'done' | 'failed' | 'error'
|
|
|
|
type Props = {
|
|
lines: TerminalLine[]
|
|
status: TerminalStatus
|
|
error?: string | null
|
|
className?: string
|
|
}
|
|
|
|
export default function StreamingTerminal({ lines, status, error, className = '' }: Props) {
|
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [lines])
|
|
|
|
const statusBar = () => {
|
|
if (status === 'running') {
|
|
return (
|
|
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground border-t border-border">
|
|
<span className="inline-block size-2 rounded-full bg-amber-400 animate-pulse" />
|
|
Running…
|
|
</div>
|
|
)
|
|
}
|
|
if (status === 'done') {
|
|
return (
|
|
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-green-400 border-t border-border">
|
|
<span className="inline-block size-2 rounded-full bg-green-400" />
|
|
Completed successfully
|
|
</div>
|
|
)
|
|
}
|
|
if (status === 'failed') {
|
|
return (
|
|
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 border-t border-border">
|
|
<span className="inline-block size-2 rounded-full bg-red-500" />
|
|
Exited with error
|
|
</div>
|
|
)
|
|
}
|
|
if (status === 'error') {
|
|
return (
|
|
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 border-t border-border">
|
|
<span className="inline-block size-2 rounded-full bg-red-500" />
|
|
{error ?? 'Unknown error'}
|
|
</div>
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
'flex flex-col rounded-lg border border-border bg-zinc-950 font-mono text-xs overflow-hidden ' +
|
|
className
|
|
}
|
|
>
|
|
<div className="flex-1 overflow-y-auto max-h-96 p-3 space-y-0">
|
|
{lines.length === 0 && status === 'running' && (
|
|
<span className="text-zinc-500 animate-pulse">Waiting for output…</span>
|
|
)}
|
|
{lines.map((line, i) => (
|
|
<pre
|
|
key={i}
|
|
className={
|
|
'whitespace-pre-wrap break-all leading-5 ' +
|
|
(line.type === 'stderr'
|
|
? 'text-red-400'
|
|
: line.type === 'info'
|
|
? 'text-sky-400'
|
|
: 'text-zinc-100')
|
|
}
|
|
>
|
|
{line.text}
|
|
</pre>
|
|
))}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
{statusBar()}
|
|
</div>
|
|
)
|
|
}
|