Ops-dashboard/components/StreamingTerminal.tsx
Scrum4Me Agent bdc24b57ba feat(flows): add YAML flow format, flow-runner, and /agent/v1/flow endpoint
- 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>
2026-05-13 19:22:34 +02:00

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>
)
}