"use client"; import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { ChevronDown, ChevronRight, RefreshCw, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { PipelineCanvas } from "@/components/canvas/pipeline-canvas"; import { api, type ExecutionStatus, type Run } from "@/lib/api"; import type { PipelineDefinition, PipelineNode } from "@/lib/pipeline-graph"; import { lookup as lookupNode } from "@/lib/node-catalog"; import { formatDate } from "@/lib/utils"; type NodeStatus = "pending" | "running" | "success" | "failed" | "paused"; type NodeStatuses = Record; type NodeLogs = Record; type NodeDurations = Record; interface ExecutionEvent { type: string; node?: string; node_type?: string; status?: string; content?: string; outputs?: Record; error?: string; duration_ms?: number; } export default function ExecutionPage() { return ( Loading…} > ); } function statusVariant(s?: string) { switch ((s || "").toLowerCase()) { case "success": case "completed": return "success" as const; case "running": case "pending": return "secondary" as const; case "failed": case "error": return "destructive" as const; case "waiting": case "paused": return "warning" as const; default: return "outline" as const; } } function ExecutionView() { const params = useSearchParams(); const id = params.get("id") ?? ""; const [status, setStatus] = useState(null); const [run, setRun] = useState(null); const [pipelineDef, setPipelineDef] = useState(null); const [nodeStatuses, setNodeStatuses] = useState({}); const [nodeLogs, setNodeLogs] = useState({}); const [nodeDurations, setNodeDurations] = useState({}); const [streamConnected, setStreamConnected] = useState(false); const [error, setError] = useState(null); // Resume form const [awakeable, setAwakeable] = useState(""); const [data, setData] = useState(`{"approved": true}`); const [resuming, setResuming] = useState(false); // Initial load useEffect(() => { if (!id) return; (async () => { try { const [statusRes, runs] = await Promise.all([ api.getExecution(id).catch(() => null), api.listRuns({ limit: 100 }).catch(() => [] as Run[]), ]); if (statusRes) setStatus(statusRes); const r = runs.find((x) => x.id === id) ?? null; setRun(r); if (r?.pipelineId) { try { const f = await api.getFlow(r.pipelineId); setPipelineDef((f.definition as PipelineDefinition | null) ?? null); } catch { /* pipeline may have been deleted */ } } } catch (e) { setError(e instanceof Error ? e.message : "load failed"); } })(); }, [id]); // SSE useEffect(() => { if (!id) return; const es = new EventSource(api.executionStreamURL(id)); es.onopen = () => setStreamConnected(true); es.onerror = () => setStreamConnected(false); es.onmessage = (msg) => { try { const ev: ExecutionEvent = JSON.parse(msg.data); if (ev.type === "node_log" && ev.node && typeof ev.content === "string") { const node = ev.node; setNodeLogs((prev) => { const cur = prev[node] ?? []; const next = cur.length >= 2000 ? cur.slice(-1999) : cur; return { ...prev, [node]: [...next, ev.content!] }; }); return; } if (ev.node) { setNodeStatuses((prev) => { const next: NodeStatus = ev.type === "node_started" ? "running" : ev.type === "node_completed" ? "success" : ev.type === "node_error" ? "failed" : (prev[ev.node!] ?? "pending"); return { ...prev, [ev.node!]: next }; }); if (ev.type === "node_completed" || ev.type === "node_error") { if (typeof ev.duration_ms === "number") { setNodeDurations((prev) => ({ ...prev, [ev.node!]: ev.duration_ms!, })); } } } if (ev.type === "done") { api.getExecution(id).then(setStatus).catch(() => {}); es.close(); setStreamConnected(false); } } catch { /* ignore */ } }; return () => { es.close(); setStreamConnected(false); }; }, [id]); // Fallback polling when SSE drops useEffect(() => { if (!id || streamConnected) return; const t = setInterval(() => { api.getExecution(id).then(setStatus).catch(() => {}); }, 4000); return () => clearInterval(t); }, [id, streamConnected]); // Hydrate canvas node statuses from the terminal payload whenever we // have status + pipeline def. This handles the "joined after the run // finished" case — without it, the canvas stays at PENDING forever // because SSE has nothing left to replay. SSE-derived statuses are not // overwritten so an in-flight run is still authoritative. useEffect(() => { if (!status || !pipelineDef) return; const overall = String(status.status || "").toLowerCase(); const isTerm = ["success", "completed", "failed", "error", "partial_error"].includes(overall); if (!isTerm) return; setNodeStatuses((prev) => { const next: NodeStatuses = { ...prev }; for (const n of pipelineDef.nodes ?? []) { if (next[n.name]) continue; // SSE wins next[n.name] = inferStatus(status, n.name); } return next; }); }, [status, pipelineDef]); async function onResume() { setResuming(true); setError(null); try { let payload: unknown = {}; if (data.trim()) payload = JSON.parse(data); await api.resumeExecution(id, { awakeable_id: awakeable, data: payload }); const s = await api.getExecution(id).catch(() => null); if (s) setStatus(s); } catch (e) { setError(e instanceof Error ? e.message : "resume failed"); } finally { setResuming(false); } } const overallStatus = useMemo( () => (status?.status as string) || run?.status || "running", [status, run] ); const isTerminal = ["success", "completed", "failed", "error", "partial_error"] .includes(overallStatus.toLowerCase()); const nodes: PipelineNode[] = pipelineDef?.nodes ?? []; if (!id) { return (
Missing id query param.
); } return (
{/* Header */}

Run

{id}
{run?.pipelineId && ( pipeline {run.pipelineId.slice(0, 8)} )}
{overallStatus}
{error && ( {error} )} {/* Pipeline canvas card */} Pipeline {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
{pipelineDef ? ( ) : (
{run?.pipelineId ? "Loading pipeline canvas…" : "No pipeline definition for this run."}
)}
{/* Nodes panel */} Nodes Per-node detail. Build logs stream live and archive to S3 when the node finishes. {nodes.length === 0 ? (

No nodes loaded.

) : ( nodes.map((n) => ( | undefined} /> )) )}
{/* Resume overlay if paused */} {(overallStatus === "paused" || overallStatus === "waiting") && ( Resume Resolve a Restate awakeable to continue.
setAwakeable(e.target.value)} placeholder="awk_…" className="font-mono text-xs" />