diff --git a/frontend/src/components/actors/actors-actor-details.tsx b/frontend/src/components/actors/actors-actor-details.tsx index 13eb7ad3ea..4cd720e59d 100644 --- a/frontend/src/components/actors/actors-actor-details.tsx +++ b/frontend/src/components/actors/actors-actor-details.tsx @@ -19,6 +19,7 @@ import { ActorStateTab } from "./actor-state-tab"; import { QueriedActorStatus } from "./actor-status"; import { ActorStopButton } from "./actor-stop-button"; import { ActorTracesTab } from "./actor-traces-tab"; +import { ActorWorkflowTab } from "./workflow/actor-workflow-tab"; import { useActorsView } from "./actors-view-context-provider"; import { ActorConsole } from "./console/actor-console"; import { @@ -148,6 +149,14 @@ export function ActorTabs({ Traces + + Workflow + + {/* {guardContent || } + + {guardContent || } + + Workflow Visualizer is currently unavailable. +
+ See console/logs for more details. + + ); + } + + if (isLoading) { + return ( + +
+ + Loading Workflow... +
+
+ ); + } + + if (!isWorkflowEnabled) { + return ( + +

+ Workflow Visualizer is not enabled for this Actor.
This + feature requires a workflow-based Actor. +

+
+ ); + } + + return ( +
+ +
+ ); +} + +function Info({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/actors/workflow/workflow-types.ts b/frontend/src/components/actors/workflow/workflow-types.ts new file mode 100644 index 0000000000..95ade7c026 --- /dev/null +++ b/frontend/src/components/actors/workflow/workflow-types.ts @@ -0,0 +1,183 @@ +// Workflow types for visualization + +export type NameIndex = number; + +export interface LoopIterationMarker { + loop: NameIndex; + iteration: number; +} + +export type PathSegment = NameIndex | LoopIterationMarker; +export type Location = PathSegment[]; + +export type SleepState = "pending" | "completed" | "interrupted"; + +export type BranchStatusType = + | "pending" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type WorkflowState = + | "pending" + | "running" + | "sleeping" + | "failed" + | "completed" + | "cancelled" + | "rolling_back"; + +export interface StepEntry { + output?: unknown; + error?: string; +} + +export interface LoopEntry { + state: unknown; + iteration: number; + output?: unknown; +} + +export interface SleepEntry { + deadline: number; + state: SleepState; +} + +export interface MessageEntry { + name: string; + data: unknown; +} + +export interface RollbackCheckpointEntry { + name: string; +} + +export interface BranchStatus { + status: BranchStatusType; + output?: unknown; + error?: string; +} + +export interface JoinEntry { + branches: Record; +} + +export interface RaceEntry { + winner: string | null; + branches: Record; +} + +export interface RemovedEntry { + originalType: EntryKindType; + originalName?: string; +} + +export type EntryKindType = + | "step" + | "loop" + | "sleep" + | "message" + | "rollback_checkpoint" + | "join" + | "race" + | "removed"; + +export type EntryKind = + | { type: "step"; data: StepEntry } + | { type: "loop"; data: LoopEntry } + | { type: "sleep"; data: SleepEntry } + | { type: "message"; data: MessageEntry } + | { type: "rollback_checkpoint"; data: RollbackCheckpointEntry } + | { type: "join"; data: JoinEntry } + | { type: "race"; data: RaceEntry } + | { type: "removed"; data: RemovedEntry }; + +export type EntryStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "retrying"; + +// Extended type for visualization (includes meta nodes) +export type ExtendedEntryType = EntryKindType | "input" | "output"; + +export interface Entry { + id: string; + location: Location; + kind: EntryKind; + dirty: boolean; + status?: EntryStatus; + startedAt?: number; + completedAt?: number; + retryCount?: number; + error?: string; +} + +export interface HistoryItem { + key: string; + entry: Entry; +} + +export interface WorkflowHistory { + workflowId: string; + state: WorkflowState; + nameRegistry: string[]; + history: HistoryItem[]; + input?: unknown; + output?: unknown; +} + +// Parsed node for visualization +export interface ParsedNode { + id: string; + key: string; + name: string; + type: ExtendedEntryType; + data: unknown; + locationIndex: number; + status: EntryStatus; + startedAt?: number; + completedAt?: number; + duration?: number; + retryCount?: number; + error?: string; +} + +export interface ParsedBranch { + name: string; + status: BranchStatusType; + isWinner?: boolean; + nodes: ParsedNode[]; + output?: unknown; + error?: string; +} + +export interface ParsedLoop { + node: ParsedNode; + iterations: { iteration: number; nodes: ParsedNode[] }[]; +} + +export interface ParsedJoin { + node: ParsedNode; + branches: ParsedBranch[]; +} + +export interface ParsedRace { + node: ParsedNode; + winner: string | null; + branches: ParsedBranch[]; +} + +export type ParsedElement = + | { type: "node"; node: ParsedNode } + | { type: "loop"; loop: ParsedLoop } + | { type: "join"; join: ParsedJoin } + | { type: "race"; race: ParsedRace }; + +export interface ParsedWorkflow { + workflowId: string; + state: WorkflowState; + elements: ParsedElement[]; +} diff --git a/frontend/src/components/actors/workflow/workflow-visualizer.tsx b/frontend/src/components/actors/workflow/workflow-visualizer.tsx new file mode 100644 index 0000000000..b17c2fcbf5 --- /dev/null +++ b/frontend/src/components/actors/workflow/workflow-visualizer.tsx @@ -0,0 +1,1876 @@ +"use client"; + +import { + faPlay, + faRefresh, + faClock, + faEnvelope, + faFlag, + faCodeMerge, + faBolt, + faTrash, + faMagnifyingGlassPlus, + faMagnifyingGlassMinus, + faMaximize, + faRotateLeft, + faCircleCheck, + faCircleExclamation, + faSpinnerThird, + faArrowDown, + faArrowUp, + faXmark, + Icon, +} from "@rivet-gg/icons"; +import { useState, useRef, useCallback, useMemo, useEffect } from "react"; +import { cn } from "@/components"; +import type { + WorkflowHistory, + EntryKindType, + ExtendedEntryType, + EntryStatus, + HistoryItem, + LoopEntry, + JoinEntry, + RaceEntry, + MessageEntry, + RemovedEntry, + StepEntry, + SleepEntry, + LoopIterationMarker, +} from "./workflow-types"; + +// Layout constants +const NODE_WIDTH = 200; +const NODE_HEIGHT = 52; +const NODE_HEIGHT_DETAILED = 100; +const NODE_GAP_Y = 32; +const BRANCH_GAP_X = 48; +const BRANCH_GAP_Y = 48; +const LOOP_PADDING_X = 24; +const LOOP_PADDING_Y = 20; +const ITERATION_HEADER = 28; + +// Extended type for meta nodes +type MetaExtendedEntryType = EntryKindType | "input" | "output"; + +// Type colors - subtle with colored icon boxes +const TYPE_COLORS: Record< + MetaExtendedEntryType, + { bg: string; border: string; icon: string; iconBg: string } +> = { + step: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#3b82f6", + iconBg: "#3b82f615", + }, + loop: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#a855f7", + iconBg: "#a855f715", + }, + sleep: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#f59e0b", + iconBg: "#f59e0b15", + }, + message: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#10b981", + iconBg: "#10b98115", + }, + rollback_checkpoint: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#ec4899", + iconBg: "#ec489915", + }, + join: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#06b6d4", + iconBg: "#06b6d415", + }, + race: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#ec4899", + iconBg: "#ec489915", + }, + removed: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#71717a", + iconBg: "#71717a15", + }, + input: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#22c55e", + iconBg: "#22c55e15", + }, + output: { + bg: "hsl(var(--card))", + border: "hsl(var(--border))", + icon: "#22c55e", + iconBg: "#22c55e15", + }, +}; + +// Icons +function TypeIcon({ + type, + size = 14, +}: { + type: MetaExtendedEntryType; + size?: number; +}) { + const color = TYPE_COLORS[type].icon; + + switch (type) { + case "step": + return ; + case "loop": + return ; + case "sleep": + return ; + case "message": + return ; + case "rollback_checkpoint": + return ; + case "join": + return ; + case "race": + return ; + case "removed": + return ; + case "input": + return ; + case "output": + return ; + default: + return ; + } +} + +// Get display name from key +function getDisplayName(key: string): string { + const parts = key.split("/"); + let name = parts[parts.length - 1]; + // Remove iteration prefix + name = name.replace(/^~\d+\//, ""); + return name; +} + +// Get short summary of entry data +function getEntrySummary(type: ExtendedEntryType, data: unknown): string { + switch (type) { + case "step": { + const d = data as StepEntry; + if (d.error) return "error"; + if (d.output === true) return "success"; + if (typeof d.output === "number") return String(d.output); + return "completed"; + } + case "sleep": { + const d = data as SleepEntry; + return d.state; + } + case "message": { + const d = data as MessageEntry; + return d.name.split(":").pop() || "received"; + } + case "loop": { + const d = data as LoopEntry; + return `${d.iteration} iterations`; + } + case "rollback_checkpoint": { + return "checkpoint"; + } + case "join": { + const d = data as JoinEntry; + const completed = Object.values(d.branches).filter( + (b) => b.status === "completed", + ).length; + return `${completed}/${Object.keys(d.branches).length} done`; + } + case "race": { + const d = data as RaceEntry; + return d.winner ? `winner: ${d.winner}` : "racing"; + } + case "removed": { + const d = data as RemovedEntry; + return d.originalType; + } + case "input": + case "output": { + const d = data as { value: unknown }; + if (typeof d.value === "object" && d.value !== null) { + const keys = Object.keys(d.value); + return keys.length > 0 ? `${keys.length} fields` : "empty"; + } + return String(d.value); + } + default: + return ""; + } +} + +// Parsed structures +interface WorkflowNode { + id: string; + key: string; + name: string; + type: EntryKindType; + data: unknown; + locationIndex: number; + status: EntryStatus; + startedAt?: number; + completedAt?: number; + duration?: number; + retryCount?: number; + error?: string; +} + +interface LayoutNode { + node: WorkflowNode; + x: number; + y: number; + gapFromPrev?: number; +} + +interface LayoutConnection { + id: string; + x1: number; + y1: number; + x2: number; + y2: number; + type: "normal" | "branch" | "merge"; + deltaMs?: number; +} + +interface LayoutLoop { + id: string; + label: string; + iterations: number; + x: number; + y: number; + width: number; + height: number; +} + +interface LayoutBranchGroup { + id: string; + type: "join" | "race"; + label: string; + winner?: string | null; + branches: { + name: string; + isWinner?: boolean; + isCancelled?: boolean; + x: number; + y: number; + width: number; + height: number; + nodes: LayoutNode[]; + }[]; + x: number; + y: number; + width: number; + height: number; +} + +// Parse workflow history +function parseAndLayout( + history: WorkflowHistory, + centerX: number, + detailedMode = false, + showAllDeltas = false, +) { + const { history: items } = history; + const nodeHeight = detailedMode ? NODE_HEIGHT_DETAILED : NODE_HEIGHT; + const gapY = showAllDeltas ? NODE_GAP_Y + 20 : NODE_GAP_Y; + + // Separate top-level from nested + const topLevel: HistoryItem[] = []; + const nestedByParent = new Map(); + + for (const item of items) { + const loc = item.entry.location; + if (loc.length === 1 && typeof loc[0] === "number") { + topLevel.push(item); + } else { + const parentKey = item.key.split("/")[0]; + if (!nestedByParent.has(parentKey)) { + nestedByParent.set(parentKey, []); + } + nestedByParent.get(parentKey)?.push(item); + } + } + + // Sort by location + topLevel.sort( + (a, b) => (a.entry.location[0] as number) - (b.entry.location[0] as number), + ); + + const layoutNodes: LayoutNode[] = []; + const connections: LayoutConnection[] = []; + const loops: LayoutLoop[] = []; + const branchGroups: LayoutBranchGroup[] = []; + + let currentY = 40; + let prevNodeCenter: { x: number; y: number } | null = null; + let prevCompletedAt: number | null = null; + + // Add input meta node if workflow has input + if (history.input !== undefined) { + const inputNode: WorkflowNode = { + id: "meta-input", + key: "input", + name: "Input", + type: "input" as EntryKindType, + data: { value: history.input }, + locationIndex: -1, + status: "completed", + }; + + layoutNodes.push({ + node: inputNode, + x: centerX - NODE_WIDTH / 2, + y: currentY, + }); + + prevNodeCenter = { x: centerX, y: currentY + nodeHeight }; + currentY += nodeHeight + gapY; + } + + for (const item of topLevel) { + const entryType = item.entry.kind.type; + const startedAt = item.entry.startedAt; + const completedAt = item.entry.completedAt; + const duration = + startedAt && completedAt ? completedAt - startedAt : undefined; + + // Determine status - use explicit status if provided, otherwise infer from timestamps + const status: EntryStatus = + item.entry.status || + (completedAt ? "completed" : startedAt ? "running" : "pending"); + + const node: WorkflowNode = { + id: item.entry.id, + key: item.key, + name: getDisplayName(item.key), + type: entryType, + data: item.entry.kind.data, + locationIndex: item.entry.location[0] as number, + status, + startedAt, + completedAt, + duration, + retryCount: item.entry.retryCount, + error: item.entry.error, + }; + + if (entryType === "loop") { + // Process loop with iterations + const loopData = item.entry.kind.data as LoopEntry; + const loopItems = nestedByParent.get(item.key) || []; + + // Group by iteration + const iterationMap = new Map(); + for (const li of loopItems) { + const marker = li.entry.location.find( + (s): s is LoopIterationMarker => + typeof s === "object" && "iteration" in s, + ); + if (marker) { + if (!iterationMap.has(marker.iteration)) { + iterationMap.set(marker.iteration, []); + } + iterationMap.get(marker.iteration)?.push(li); + } + } + + const sortedIterations = Array.from(iterationMap.entries()).sort( + (a, b) => a[0] - b[0], + ); + + // Calculate loop dimensions + let loopContentHeight = 0; + const iterationLayouts: { + iteration: number; + nodes: LayoutNode[]; + height: number; + }[] = []; + + for (const [iterNum, iterItems] of sortedIterations) { + iterItems.sort((a, b) => { + const aLoc = a.entry.location[ + a.entry.location.length - 1 + ] as number; + const bLoc = b.entry.location[ + b.entry.location.length - 1 + ] as number; + return aLoc - bLoc; + }); + + const iterNodes: LayoutNode[] = []; + let nodeY = 0; + for (const li of iterItems) { + const iterStartedAt = li.entry.startedAt; + const iterCompletedAt = li.entry.completedAt; + const iterDuration = + iterStartedAt && iterCompletedAt + ? iterCompletedAt - iterStartedAt + : undefined; + + const iterNode: WorkflowNode = { + id: li.entry.id, + key: li.key, + name: getDisplayName(li.key), + type: li.entry.kind.type, + data: li.entry.kind.data, + locationIndex: li.entry.location[ + li.entry.location.length - 1 + ] as number, + status: li.entry.status || "completed", + startedAt: iterStartedAt, + completedAt: iterCompletedAt, + duration: iterDuration, + }; + iterNodes.push({ node: iterNode, x: 0, y: nodeY }); + nodeY += nodeHeight + NODE_GAP_Y; + } + + const iterHeight = + iterItems.length > 0 + ? iterItems.length * nodeHeight + + (iterItems.length - 1) * NODE_GAP_Y + : 0; + iterationLayouts.push({ + iteration: iterNum, + nodes: iterNodes, + height: iterHeight, + }); + loopContentHeight += iterHeight + ITERATION_HEADER + 16; + } + + if (iterationLayouts.length === 0) { + loopContentHeight = 60; + } + + const loopHeight = loopContentHeight + LOOP_PADDING_Y * 2; + const loopWidth = NODE_WIDTH + LOOP_PADDING_X * 2; + const loopX = centerX - loopWidth / 2; + const loopY = currentY; + + // Connection from previous + if (prevNodeCenter) { + connections.push({ + id: `conn-to-loop-${item.entry.id}`, + x1: prevNodeCenter.x, + y1: prevNodeCenter.y, + x2: centerX, + y2: loopY, + type: "normal", + }); + } + + loops.push({ + id: item.entry.id, + label: node.name, + iterations: loopData.iteration, + x: loopX, + y: loopY, + width: loopWidth, + height: loopHeight, + }); + + // Position iteration nodes + let iterY = loopY + LOOP_PADDING_Y; + let prevIterLastNode: LayoutNode | null = null; + + for (const { iteration, nodes: iterNodes, height } of iterationLayouts) { + iterY += ITERATION_HEADER; + for (let i = 0; i < iterNodes.length; i++) { + const ln = iterNodes[i]; + ln.x = centerX - NODE_WIDTH / 2; + ln.y = iterY + ln.y; + layoutNodes.push(ln); + + // Connect within iteration + if (i > 0) { + const prev = iterNodes[i - 1]; + const prevCompletedAtTs = prev.node.completedAt; + const currStartedAtTs = ln.node.startedAt; + const deltaMs = + prevCompletedAtTs && currStartedAtTs + ? currStartedAtTs - prevCompletedAtTs + : undefined; + connections.push({ + id: `conn-iter-${iteration}-${i}`, + x1: centerX, + y1: prev.y + nodeHeight, + x2: centerX, + y2: ln.y, + type: "normal", + deltaMs, + }); + } else if (prevIterLastNode) { + // Connect first node of this iteration to last node of previous iteration + const prevCompletedAtTs = prevIterLastNode.node.completedAt; + const currStartedAtTs = ln.node.startedAt; + const deltaMs = + prevCompletedAtTs && currStartedAtTs + ? currStartedAtTs - prevCompletedAtTs + : undefined; + connections.push({ + id: `conn-iter-bridge-${iteration}`, + x1: centerX, + y1: prevIterLastNode.y + nodeHeight, + x2: centerX, + y2: ln.y, + type: "normal", + deltaMs, + }); + } + } + + // Track last node of this iteration for bridging + if (iterNodes.length > 0) { + prevIterLastNode = iterNodes[iterNodes.length - 1]; + } + + iterY += height + 16; + } + + currentY = loopY + loopHeight + NODE_GAP_Y; + prevNodeCenter = { x: centerX, y: loopY + loopHeight }; + prevCompletedAt = completedAt ?? null; + } else if (entryType === "join" || entryType === "race") { + // Parallel branches + const branchData = + entryType === "join" + ? (item.entry.kind.data as JoinEntry) + : (item.entry.kind.data as RaceEntry); + const branchNames = Object.keys(branchData.branches); + const winner = + entryType === "race" ? (branchData as RaceEntry).winner : null; + + // Add header node for the join/race + const headerNode: LayoutNode = { + node, + x: centerX - NODE_WIDTH / 2, + y: currentY, + }; + layoutNodes.push(headerNode); + + if (prevNodeCenter) { + connections.push({ + id: `conn-to-${node.id}`, + x1: prevNodeCenter.x, + y1: prevNodeCenter.y, + x2: centerX, + y2: currentY, + type: "normal", + }); + } + + const headerBottom = currentY + nodeHeight; + + // First pass: check if any branch has a significant delta from header + let maxHeaderDelta = 0; + for (const branchName of branchNames) { + const branchItems = (nestedByParent.get(item.key) || []).filter( + (bi) => bi.key.includes(`/${branchName}/`), + ); + if (branchItems.length > 0) { + const firstItem = branchItems.reduce((min, bi) => { + const loc = bi.entry.location[ + bi.entry.location.length - 1 + ] as number; + const minLoc = min.entry.location[ + min.entry.location.length - 1 + ] as number; + return loc < minLoc ? bi : min; + }, branchItems[0]); + const delta = + completedAt && firstItem.entry.startedAt + ? firstItem.entry.startedAt - completedAt + : 0; + maxHeaderDelta = Math.max(maxHeaderDelta, delta); + } + } + + // Add extra space if there's a significant delta to display + const deltaSpace = maxHeaderDelta >= 500 ? 24 : 0; + currentY = headerBottom + BRANCH_GAP_Y + deltaSpace; + + // Calculate branch layouts + const branchLayouts: LayoutBranchGroup["branches"] = []; + let maxBranchHeight = 0; + + for (const branchName of branchNames) { + const branchItems = (nestedByParent.get(item.key) || []).filter( + (bi) => bi.key.includes(`/${branchName}/`), + ); + branchItems.sort((a, b) => { + const aLoc = a.entry.location[ + a.entry.location.length - 1 + ] as number; + const bLoc = b.entry.location[ + b.entry.location.length - 1 + ] as number; + return aLoc - bLoc; + }); + + const branchNodes: LayoutNode[] = []; + let nodeY = 0; + for (const bi of branchItems) { + const bnStartedAt = bi.entry.startedAt; + const bnCompletedAt = bi.entry.completedAt; + const bnDuration = + bnStartedAt && bnCompletedAt + ? bnCompletedAt - bnStartedAt + : undefined; + + const bn: WorkflowNode = { + id: bi.entry.id, + key: bi.key, + name: getDisplayName(bi.key), + type: bi.entry.kind.type, + data: bi.entry.kind.data, + locationIndex: bi.entry.location[ + bi.entry.location.length - 1 + ] as number, + status: bi.entry.status || "completed", + startedAt: bnStartedAt, + completedAt: bnCompletedAt, + duration: bnDuration, + }; + branchNodes.push({ node: bn, x: 0, y: nodeY }); + nodeY += nodeHeight + NODE_GAP_Y; + } + + const branchHeight = + branchItems.length > 0 + ? branchItems.length * nodeHeight + + (branchItems.length - 1) * NODE_GAP_Y + + 40 + : 60; + maxBranchHeight = Math.max(maxBranchHeight, branchHeight); + + branchLayouts.push({ + name: branchName, + isWinner: branchName === winner, + isCancelled: + entryType === "race" && winner !== null && branchName !== winner, + x: 0, + y: 0, + width: NODE_WIDTH, + height: branchHeight, + nodes: branchNodes, + }); + } + + // Position branches horizontally + const containerWidth = NODE_WIDTH + 40; + const totalWidth = + branchLayouts.length * containerWidth + + (branchLayouts.length - 1) * BRANCH_GAP_X; + let branchX = centerX - totalWidth / 2; + + for (const branch of branchLayouts) { + branch.x = branchX; + branch.y = currentY; + + // Position nodes within branch + const branchLabelOffset = 48; + const branchPaddingX = 20; + const branchCenterX = branchX + branchPaddingX + NODE_WIDTH / 2; + + for (let i = 0; i < branch.nodes.length; i++) { + const ln = branch.nodes[i]; + ln.x = branchX + branchPaddingX; + ln.y = currentY + branchLabelOffset + ln.y; + layoutNodes.push(ln); + + // Connect within branch + if (i > 0) { + const prev = branch.nodes[i - 1]; + const prevCompletedAtLocal = prev.node.completedAt; + const currStartedAt = ln.node.startedAt; + const deltaMs = + prevCompletedAtLocal && currStartedAt + ? currStartedAt - prevCompletedAtLocal + : undefined; + connections.push({ + id: `conn-branch-${branch.name}-${i}`, + x1: branchCenterX, + y1: prev.y + nodeHeight, + x2: branchCenterX, + y2: ln.y, + type: "normal", + deltaMs, + }); + } + } + + // Connection from header to first node in branch + const firstBranchNode = branch.nodes[0]; + const headerToFirstDelta = + completedAt && firstBranchNode?.node.startedAt + ? firstBranchNode.node.startedAt - completedAt + : undefined; + connections.push({ + id: `conn-to-branch-${branch.name}`, + x1: centerX, + y1: headerBottom, + x2: branchCenterX, + y2: firstBranchNode + ? firstBranchNode.y + : currentY + branchLabelOffset, + type: "branch", + deltaMs: headerToFirstDelta, + }); + + branchX += containerWidth + BRANCH_GAP_X; + } + + // Container height = branch.height + 48 (label offset) + 20 (bottom padding) + const containerPadding = 48 + 20; + + branchGroups.push({ + id: item.entry.id, + type: entryType, + label: getDisplayName(item.key), + winner: entryType === "race" ? winner : undefined, + branches: branchLayouts, + x: centerX - totalWidth / 2, + y: currentY, + width: totalWidth, + height: maxBranchHeight + containerPadding, + }); + + // Merge connections + const mergeY = currentY + maxBranchHeight + containerPadding + BRANCH_GAP_Y; + for (const branch of branchLayouts) { + if (!branch.isCancelled) { + const lastBranchNode = branch.nodes[branch.nodes.length - 1]; + const lastNodeCompletedAt = lastBranchNode?.node.completedAt; + const branchPaddingX = 20; + const branchCenterX = branch.x + branchPaddingX + NODE_WIDTH / 2; + const containerBottom = branch.y + branch.height + containerPadding; + connections.push({ + id: `conn-merge-${branch.name}`, + x1: branchCenterX, + y1: containerBottom, + x2: centerX, + y2: mergeY, + type: "merge", + deltaMs: + lastNodeCompletedAt && completedAt + ? completedAt - lastNodeCompletedAt + : undefined, + }); + } + } + + currentY = mergeY; + prevNodeCenter = null; + prevCompletedAt = completedAt ?? null; + } else { + // Regular sequential node + let gapFromPrev: number | undefined; + if (prevCompletedAt && startedAt) { + gapFromPrev = startedAt - prevCompletedAt; + } + + const layoutNode: LayoutNode = { + node, + x: centerX - NODE_WIDTH / 2, + y: currentY, + gapFromPrev, + }; + layoutNodes.push(layoutNode); + + if (prevNodeCenter) { + connections.push({ + id: `conn-to-${node.id}`, + x1: prevNodeCenter.x, + y1: prevNodeCenter.y, + x2: centerX, + y2: currentY, + type: "normal", + deltaMs: gapFromPrev, + }); + } + + prevNodeCenter = { x: centerX, y: currentY + nodeHeight }; + prevCompletedAt = completedAt ?? null; + currentY += nodeHeight + gapY; + } + } + + // Calculate total width from all positioned elements + let maxX = centerX + NODE_WIDTH / 2; + + // Add output meta node if workflow has output (only for completed workflows) + if (history.output !== undefined && history.state === "completed") { + const outputNode: WorkflowNode = { + id: "meta-output", + key: "output", + name: "Output", + type: "output" as EntryKindType, + data: { value: history.output }, + locationIndex: 9999, + status: "completed", + }; + + // Add connection from last node to output + if (prevNodeCenter) { + connections.push({ + id: "conn-to-output", + x1: prevNodeCenter.x, + y1: prevNodeCenter.y, + x2: centerX, + y2: currentY, + type: "normal", + }); + } + + layoutNodes.push({ + node: outputNode, + x: centerX - NODE_WIDTH / 2, + y: currentY, + }); + + currentY += nodeHeight + gapY; + } + + for (const ln of layoutNodes) { + maxX = Math.max(maxX, ln.x + NODE_WIDTH); + } + for (const loop of loops) { + maxX = Math.max(maxX, loop.x + loop.width); + } + for (const group of branchGroups) { + for (const branch of group.branches) { + maxX = Math.max(maxX, branch.x + branch.width); + } + } + + return { + nodes: layoutNodes, + connections, + loops, + branchGroups, + totalWidth: maxX + 60, + totalHeight: currentY + 60, + }; +} + +// Format duration for display +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`; + return `${(ms / 3600000).toFixed(1)}h`; +} + +// SVG Node - reports hover to parent for popover rendering +function SVGNode({ + node, + x, + y, + selected, + onSelect, + onHover, + detailedMode, +}: { + node: WorkflowNode; + x: number; + y: number; + selected: boolean; + onSelect: (node: WorkflowNode) => void; + onHover: (node: WorkflowNode | null, x: number, y: number) => void; + gapFromPrev?: number; + detailedMode?: boolean; +}) { + const colors = TYPE_COLORS[node.type as MetaExtendedEntryType]; + const summary = getEntrySummary(node.type, node.data); + const duration = node.duration; + const height = detailedMode ? NODE_HEIGHT_DETAILED : NODE_HEIGHT; + const isRunning = node.status === "running"; + const isFailed = node.status === "failed"; + const isRetrying = node.status === "retrying"; + + // Get data preview for detailed mode + const dataPreview = detailedMode + ? JSON.stringify(node.data, null, 2).slice(0, 120) + : ""; + + return ( + + {/* biome-ignore lint/a11y/noStaticElementInteractions: SVG node for workflow visualization */} + { + e.stopPropagation(); + onSelect(node); + }} + onMouseEnter={() => onHover(node, x, y)} + onMouseLeave={() => onHover(null, 0, 0)} + className="cursor-pointer" + > + {/* Card background */} + + {/* Retry count badge */} + {node.retryCount && node.retryCount > 0 && ( + + + + x{node.retryCount} + + + )} + {/* Duration - bottom right inside card (only when not running/retrying) */} + {duration !== undefined && + !node.retryCount && + !isRunning && + !isRetrying && ( + + {formatDuration(duration)} + + )} + {/* Status indicator - right side for running/retrying */} + {(isRunning || isRetrying) && ( + + + + )} + {isFailed && ( + + + + )} + {/* Icon box with color */} + + + + + {/* Text */} + + {node.name.length > 18 ? `${node.name.slice(0, 18)}...` : node.name} + + + {summary} + + {/* Detailed mode: show data preview */} + {detailedMode && ( + +
+ {dataPreview} + {dataPreview.length >= 120 ? "..." : ""} +
+
+ )} +
+
+ ); +} + +// Connection with spacing for arrowhead and delta display on hover +function Connection({ + x1, + y1, + x2, + y2, + type, + deltaMs, + showAllDeltas, +}: LayoutConnection & { showAllDeltas?: boolean }) { + const [isHovered, setIsHovered] = useState(false); + + // Add spacing at the end for the arrowhead + const arrowGap = 8; + const adjustedY2 = y2 - arrowGap; + + const radius = 8; + + // Show delta: always if >= 500ms, on hover for smaller, or always if showAllDeltas + const isSignificantDelta = deltaMs !== undefined && deltaMs >= 500; + const shouldShowDelta = + deltaMs !== undefined && (isSignificantDelta || isHovered || showAllDeltas); + + // Build path based on connection type + let path: string; + if (type === "normal" || x1 === x2) { + // Straight vertical line + path = `M ${x1} ${y1 + 4} L ${x2} ${adjustedY2}`; + } else { + // Branching path + const startY = y1 + 4; + const midY = startY + 20; + const endY = adjustedY2; + + const goingRight = x2 > x1; + const hDir = goingRight ? 1 : -1; + + path = `M ${x1} ${startY} + L ${x1} ${midY - radius} + Q ${x1} ${midY} ${x1 + radius * hDir} ${midY} + L ${x2 - radius * hDir} ${midY} + Q ${x2} ${midY} ${x2} ${midY + radius} + L ${x2} ${endY}`; + } + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: SVG connection for workflow visualization + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ cursor: deltaMs !== undefined ? "pointer" : "default" }} + > + {/* Invisible wider path for easier hover targeting */} + + {/* Visible path */} + + {shouldShowDelta && + (() => { + let textX: number; + let textY: number; + + if (type === "normal" || x1 === x2) { + textX = x1 + 12; + textY = (y1 + y2) / 2; + } else { + const midY = y1 + 4 + 20; + textX = x2 + 12; + textY = (midY + y2) / 2; + } + + return ( + + {deltaMs !== undefined && formatDuration(deltaMs)} later + + ); + })()} + + ); +} + +// Main component +export function WorkflowVisualizer({ + workflow, +}: { + workflow: WorkflowHistory; +}) { + const [transform, setTransform] = useState({ x: 60, y: 60, scale: 1 }); + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [selectedNode, setSelectedNode] = useState(null); + const [hoveredNode, setHoveredNode] = useState<{ + node: WorkflowNode; + x: number; + y: number; + } | null>(null); + const [detailedMode, setDetailedMode] = useState(false); + + const containerRef = useRef(null); + const [hasInitialized, setHasInitialized] = useState(false); + const [showAllDeltas, setShowAllDeltas] = useState(false); + + const handleNodeHover = useCallback( + (node: WorkflowNode | null, x: number, y: number) => { + if (node) { + setHoveredNode({ node, x, y }); + } else { + setHoveredNode(null); + } + }, + [], + ); + + const layout = useMemo( + () => parseAndLayout(workflow, 350, detailedMode, showAllDeltas), + [workflow, detailedMode, showAllDeltas], + ); + + // Auto-fit the workflow on initial render + useEffect(() => { + if (hasInitialized || !containerRef.current) return; + + const container = containerRef.current; + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + const padding = 80; + const contentWidth = layout.totalWidth; + const contentHeight = layout.totalHeight; + + // Calculate scale to fit content with padding + const scaleX = (containerWidth - padding * 2) / contentWidth; + const scaleY = (containerHeight - padding * 2) / contentHeight; + + // Use the smaller scale to fit both dimensions, max out at 1 for short workflows + const scale = Math.min(scaleX, scaleY, 1); + + // Center the content + const scaledWidth = contentWidth * scale; + const scaledHeight = contentHeight * scale; + const x = (containerWidth - scaledWidth) / 2; + const y = (containerHeight - scaledHeight) / 2; + + setTransform({ x, y, scale }); + setHasInitialized(true); + }, [layout, hasInitialized]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button === 0 || e.button === 1) { + e.preventDefault(); + setIsPanning(true); + setPanStart({ x: e.clientX - transform.x, y: e.clientY - transform.y }); + } + }, + [transform], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (isPanning) { + setTransform((t) => ({ + ...t, + x: e.clientX - panStart.x, + y: e.clientY - panStart.y, + })); + } + }, + [isPanning, panStart], + ); + + const handleMouseUp = useCallback(() => setIsPanning(false), []); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + + if (e.ctrlKey || e.metaKey) { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const cursorX = e.clientX - rect.left; + const cursorY = e.clientY - rect.top; + + const delta = e.deltaY > 0 ? 0.9 : 1.1; + + setTransform((t) => { + const newScale = Math.min(Math.max(t.scale * delta, 0.25), 2); + const scaleFactor = newScale / t.scale; + + const newX = cursorX - (cursorX - t.x) * scaleFactor; + const newY = cursorY - (cursorY - t.y) * scaleFactor; + + return { x: newX, y: newY, scale: newScale }; + }); + } else { + setTransform((t) => ({ + ...t, + x: t.x - e.deltaX, + y: t.y - e.deltaY, + })); + } + }, []); + + const zoomIn = () => + setTransform((t) => ({ ...t, scale: Math.min(t.scale * 1.2, 2) })); + const zoomOut = () => + setTransform((t) => ({ ...t, scale: Math.max(t.scale / 1.2, 0.25) })); + const resetView = () => setTransform({ x: 60, y: 60, scale: 1 }); + const fitView = () => { + if (containerRef.current) { + const { width, height } = containerRef.current.getBoundingClientRect(); + const scale = Math.min(width / 800, height / layout.totalHeight, 1) * 0.85; + setTransform({ x: 60, y: 60, scale }); + } + }; + + return ( +
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Canvas for panning/zooming workflow visualization */} +
+ {/* Dot grid */} + + + {/* Workflow */} +
+ + + + + + + + {/* Connections */} + {layout.connections.map((conn) => ( + + ))} + + {/* Loop containers */} + {layout.loops.map((loop) => ( + + + + + + + + {loop.label}{" "} + + ({loop.iterations}x) + + + + ))} + + {/* Branch containers */} + {layout.branchGroups.map((group) => { + const baseColor = + group.type === "join" ? "#06b6d4" : "#ec4899"; + const iconDef = group.type === "join" ? faCodeMerge : faBolt; + return ( + + {group.branches.map((branch) => { + const branchColor = branch.isWinner + ? "#10b981" + : branch.isCancelled + ? "#ef4444" + : baseColor; + const containerX = branch.x; + const containerY = branch.y; + const containerWidth = branch.width + 40; + const containerHeight = branch.height + 48 + 20; + const containerCenterX = containerX + containerWidth / 2; + return ( + + + + + + + + {branch.name} + + {branch.isCancelled && ( + + + + + + + )} + + ); + })} + + ); + })} + + {/* Nodes */} + {layout.nodes.map(({ node, x, y, gapFromPrev }) => ( + + ))} + + + + {/* Zoom controls */} +
+ + +
+ + +
+ {Math.round(transform.scale * 100)}% +
+
+ + {/* Header */} +
+
+ +
+
+
+ Workflow +
+
+ {workflow.workflowId.slice(0, 8)}... | {workflow.state} +
+
+
+ + +
+ + {/* Hover popover - rendered as HTML for proper z-index */} + {hoveredNode && !detailedMode && ( +
+
+
+
+ Key +
+
+ {hoveredNode.node.key} +
+
+ {hoveredNode.node.startedAt && ( +
+
+ Started +
+
+ {new Date( + hoveredNode.node.startedAt, + ).toLocaleString()} + . + {String(hoveredNode.node.startedAt % 1000).padStart( + 3, + "0", + )} +
+
+ )} + {hoveredNode.node.completedAt && ( +
+
+ Completed +
+
+ {new Date( + hoveredNode.node.completedAt, + ).toLocaleString()} + . + {String(hoveredNode.node.completedAt % 1000).padStart( + 3, + "0", + )} +
+
+ )} + {hoveredNode.node.error && ( +
+
+ Error +
+
+ {hoveredNode.node.error} +
+
+ )} + {hoveredNode.node.retryCount && + hoveredNode.node.retryCount > 0 && ( +
+
+ Retries +
+
+ {hoveredNode.node.retryCount} attempt(s) +
+
+ )} +
+
+ Data +
+
+										{JSON.stringify(hoveredNode.node.data, null, 2).slice(
+											0,
+											200,
+										)}
+										{JSON.stringify(hoveredNode.node.data, null, 2).length >
+										200
+											? "..."
+											: ""}
+									
+
+
+
+ )} +
+
+ + {/* Bottom details panel */} + {selectedNode && ( +
+
+
+
+ +
+
+
+ {selectedNode.name} +
+
+ {selectedNode.type} +
+
+
+ +
+ +
+
+ Key +
+
+ {selectedNode.key} +
+
+ +
+
+ Data +
+
+								{JSON.stringify(selectedNode.data, null, 2)}
+							
+
+ + {selectedNode.startedAt && ( +
+
+ Started +
+
+ {new Date(selectedNode.startedAt).toLocaleString()} +
+
+ )} + + {selectedNode.completedAt && ( +
+
+ Completed +
+
+ {new Date(selectedNode.completedAt).toLocaleString()} +
+
+ )} + + {selectedNode.duration !== undefined && ( +
+
+ Duration +
+
+ {formatDuration(selectedNode.duration)} +
+
+ )} + +
+
+ Status +
+
+ {selectedNode.status} +
+
+ + {selectedNode.retryCount && selectedNode.retryCount > 0 && ( +
+
+ Retries +
+
+ {selectedNode.retryCount} +
+
+ )} + + {selectedNode.error && ( +
+
+ Error +
+
+ {selectedNode.error} +
+
+ )} + + +
+
+ )} +
+ ); +}