"use client"; import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { ArrowLeft, 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 } from "@/lib/pipeline-graph"; type NodeStatus = "pending" | "running" | "success" | "failed" | "paused"; type NodeStatuses = Record; interface ExecutionEvent { type: string; node?: string; node_type?: string; status?: string; content?: string; // for node_log events outputs?: Record; error?: string; duration_ms?: number; } type NodeLogLine = { ts: number; line: string }; type NodeLogs = Record; 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 [events, setEvents] = useState([]); const [nodeLogs, setNodeLogs] = useState({}); const [activeLogNode, setActiveLogNode] = useState(null); const [streamConnected, setStreamConnected] = useState(false); const [error, setError] = useState(null); const [tab, setTab] = useState<"canvas" | "json">("canvas"); // Resume form const [awakeable, setAwakeable] = useState(""); const [data, setData] = useState(`{"approved": true}`); const [resuming, setResuming] = useState(false); const eventSourceRef = useRef(null); // Initial load: status + run record + pipeline def. 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; render JSON view only. } } } catch (e) { setError(e instanceof Error ? e.message : "load failed"); } })(); }, [id]); // SSE subscription. On open, mark all known nodes as pending. Each // node_started/completed/error event flips that node's status. useEffect(() => { if (!id) return; const url = api.executionStreamURL(id); const es = new EventSource(url); eventSourceRef.current = es; es.onopen = () => setStreamConnected(true); es.onerror = () => { setStreamConnected(false); // EventSource auto-reconnects on transient errors; only close on // permanent ones. We log but don't bail. }; es.onmessage = (msg) => { try { const ev: ExecutionEvent = JSON.parse(msg.data); // Log lines are high-frequency — keep them out of the generic // events array (used for the JSON debug view) and route into // their own state instead. 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, { ts: Date.now(), line: ev.content! }], }; }); setActiveLogNode((prev) => prev ?? node); return; } setEvents((prev) => [...prev.slice(-99), ev]); 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_started") { setActiveLogNode(ev.node); } } if (ev.type === "done") { api.getExecution(id).then(setStatus).catch(() => {}); es.close(); setStreamConnected(false); } } catch { // Ignore malformed messages. } }; return () => { es.close(); eventSourceRef.current = null; setStreamConnected(false); }; }, [id]); // Fallback polling for terminal status — useful if SSE failed to connect. useEffect(() => { if (!id || streamConnected) return; const t = setInterval(() => { api.getExecution(id).then(setStatus).catch(() => {}); }, 3000); return () => clearInterval(t); }, [id, streamConnected]); 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(() => { return (status?.status as string) || run?.status || "running"; }, [status, run]); if (!id) { return (
Missing id query param.
); } return (
{/* Toolbar */}
Execution
{id} {overallStatus} {streamConnected ? ( ● live ) : ( polling )}
{error && (
{error}
)}
{tab === "canvas" ? (
{pipelineDef ? ( ) : (
{run?.pipelineId ? "Loading pipeline canvas…" : "No pipeline definition available for this run."}
)}
) : (
Status Latest snapshot from the orchestrator
                  {status ? JSON.stringify(status, null, 2) : "Loading…"}
                
Event log Streamed via SSE
                  {events.length === 0
                    ? "(no events yet)"
                    : events
                        .map((e) => JSON.stringify(e))
                        .join("\n")}
                
)} {/* Resume panel — overlaid only when paused */} {(overallStatus === "paused" || overallStatus === "waiting") && ( Resume Resolve the Restate awakeable to continue.
setAwakeable(e.target.value)} placeholder="awk_…" className="font-mono text-xs" />