|
|
@@ -1,8 +1,9 @@
|
|
|
-import { useMemo } from 'react'
|
|
|
+import { useEffect, useMemo, useState } from 'react'
|
|
|
import { Node, Edge } from 'reactflow'
|
|
|
-import { X, ChevronRight, ChevronDown, Loader2, CheckCircle, XCircle, Repeat } from 'lucide-react'
|
|
|
+import { X, ChevronRight, ChevronDown, Loader2, CheckCircle, XCircle, SkipForward, Ban, Repeat } from 'lucide-react'
|
|
|
import { NodeExecutionState } from './WorkflowNode'
|
|
|
import { NodeOutputView } from './NodeOutputView'
|
|
|
+import type { NodeExecution } from '../../api/workflows'
|
|
|
|
|
|
interface ExecutionState {
|
|
|
executionId: string | null
|
|
|
@@ -10,6 +11,74 @@ interface ExecutionState {
|
|
|
nodeStates: Record<string, NodeExecutionState>
|
|
|
startedAt?: number
|
|
|
completedAt?: number
|
|
|
+ // Time-sorted node execution records for a pinned/viewed execution. Only
|
|
|
+ // present there - see WorkflowEditorPage's effectiveExecutionState. Used
|
|
|
+ // here to group loop bodies by iteration once the engine has sent
|
|
|
+ // loopNodeId/loopIteration; absent (or missing those fields on every
|
|
|
+ // record) means "fall back to the flat per-node list", which is exactly
|
|
|
+ // how this panel behaved before grouping existed.
|
|
|
+ rawNodeExecutions?: NodeExecution[]
|
|
|
+}
|
|
|
+
|
|
|
+// One pass of a loop body: every node execution that shares a loopNodeId and
|
|
|
+// loopIteration, in the order they actually ran.
|
|
|
+interface LoopIterationGroup {
|
|
|
+ key: string
|
|
|
+ loopNodeId: string
|
|
|
+ iteration: number
|
|
|
+ entries: NodeExecution[]
|
|
|
+ startedAt: number
|
|
|
+ completedAt: number
|
|
|
+ hasFailure: boolean
|
|
|
+ counts: { completed: number; failed: number; skipped: number; disabled: number }
|
|
|
+}
|
|
|
+
|
|
|
+type TimelineItem =
|
|
|
+ | { kind: 'node'; startedAt: number; nodeId: string }
|
|
|
+ | { kind: 'group'; startedAt: number; group: LoopIterationGroup }
|
|
|
+
|
|
|
+// A record's status as the engine wrote it, narrowed to the four buckets the
|
|
|
+// UI distinguishes. Anything else (e.g. 'running', mid-execution) reads as
|
|
|
+// completed here rather than vanishing from a count.
|
|
|
+function bucketStatus(status: string): 'completed' | 'failed' | 'skipped' | 'disabled' {
|
|
|
+ if (status === 'failed') return 'failed'
|
|
|
+ if (status === 'skipped') return 'skipped'
|
|
|
+ if (status === 'disabled') return 'disabled'
|
|
|
+ return 'completed'
|
|
|
+}
|
|
|
+
|
|
|
+function StatusIcon({ status }: { status: string }) {
|
|
|
+ switch (status) {
|
|
|
+ case 'running':
|
|
|
+ return <Loader2 className="w-4 h-4 text-blue-500 animate-spin shrink-0" />
|
|
|
+ case 'completed':
|
|
|
+ return <CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
|
|
|
+ case 'failed':
|
|
|
+ return <XCircle className="w-4 h-4 text-red-500 shrink-0" />
|
|
|
+ case 'skipped':
|
|
|
+ return (
|
|
|
+ <span title="Skipped: on a branch not taken" className="shrink-0">
|
|
|
+ <SkipForward className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+ case 'disabled':
|
|
|
+ return (
|
|
|
+ <span title="Disabled: switched off by the author" className="shrink-0">
|
|
|
+ <Ban className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+ default:
|
|
|
+ return null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function countsLabel(counts: LoopIterationGroup['counts']): string {
|
|
|
+ const parts: string[] = []
|
|
|
+ if (counts.completed) parts.push(`${counts.completed} completed`)
|
|
|
+ if (counts.failed) parts.push(`${counts.failed} failed`)
|
|
|
+ if (counts.skipped) parts.push(`${counts.skipped} skipped`)
|
|
|
+ if (counts.disabled) parts.push(`${counts.disabled} disabled`)
|
|
|
+ return parts.join(', ')
|
|
|
}
|
|
|
|
|
|
interface ExecutionResultsPanelProps {
|
|
|
@@ -38,6 +107,25 @@ export function ExecutionResultsPanel({
|
|
|
onToggleNodeExpansion,
|
|
|
onSelectIteration,
|
|
|
}: ExecutionResultsPanelProps) {
|
|
|
+ // Which collapsed-by-default groups the user has manually opened, and
|
|
|
+ // which expanded-by-default (failed) groups they've manually closed. A
|
|
|
+ // key's presence here means "flip the default for this group". Cleared
|
|
|
+ // whenever the viewed execution changes, so opening one run doesn't leak
|
|
|
+ // its expand state into the next.
|
|
|
+ const [toggledGroups, setToggledGroups] = useState<Set<string>>(new Set())
|
|
|
+ useEffect(() => {
|
|
|
+ setToggledGroups(new Set())
|
|
|
+ }, [executionState.executionId])
|
|
|
+
|
|
|
+ const toggleGroup = (key: string) => {
|
|
|
+ setToggledGroups((prev) => {
|
|
|
+ const next = new Set(prev)
|
|
|
+ if (next.has(key)) next.delete(key)
|
|
|
+ else next.add(key)
|
|
|
+ return next
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
// Identify loop nodes and their body nodes
|
|
|
const { nodeToLoopMap, loopIterationCounts } = useMemo(() => {
|
|
|
const loopBodyMap = new Map<string, Set<string>>() // loopId -> body node IDs (used internally)
|
|
|
@@ -114,82 +202,383 @@ export function ExecutionResultsPanel({
|
|
|
return { nodeToLoopMap, loopIterationCounts }
|
|
|
}, [nodes, edges, executionState.nodeStates])
|
|
|
|
|
|
- // Sort nodes by topological order (based on workflow connections)
|
|
|
- // Nodes with execution state come first, sorted by their position in the graph
|
|
|
+ // Nodes with execution state, sorted by when they actually started. Array
|
|
|
+ // order from the API is not guaranteed to be chronological (and, before
|
|
|
+ // the sort was added on the load side too, wasn't always) - startedAt is
|
|
|
+ // the one thing that reliably reflects what happened.
|
|
|
const sortedNodes = useMemo(() => {
|
|
|
const executedNodes = nodes.filter((node) => executionState.nodeStates[node.id])
|
|
|
-
|
|
|
- if (executedNodes.length === 0) return []
|
|
|
-
|
|
|
- // Build adjacency list for topological sort
|
|
|
- const adjacencyList: Record<string, string[]> = {}
|
|
|
- const inDegree: Record<string, number> = {}
|
|
|
-
|
|
|
- // Initialize
|
|
|
- for (const node of executedNodes) {
|
|
|
- adjacencyList[node.id] = []
|
|
|
- inDegree[node.id] = 0
|
|
|
- }
|
|
|
-
|
|
|
- // Build graph from edges (only for executed nodes)
|
|
|
- const executedNodeIds = new Set(executedNodes.map(n => n.id))
|
|
|
- for (const edge of edges) {
|
|
|
- if (executedNodeIds.has(edge.source) && executedNodeIds.has(edge.target)) {
|
|
|
- adjacencyList[edge.source].push(edge.target)
|
|
|
- inDegree[edge.target] = (inDegree[edge.target] || 0) + 1
|
|
|
+ return [...executedNodes].sort((a, b) => {
|
|
|
+ const timeA = executionState.nodeStates[a.id]?.startedAt ?? Infinity
|
|
|
+ const timeB = executionState.nodeStates[b.id]?.startedAt ?? Infinity
|
|
|
+ return timeA - timeB
|
|
|
+ })
|
|
|
+ }, [nodes, executionState.nodeStates])
|
|
|
+
|
|
|
+ // True grouped-by-iteration view, built from the raw time-sorted records
|
|
|
+ // of a pinned/viewed execution. Only available once every record in a
|
|
|
+ // given loop pass carries loopNodeId/loopIteration - an execution from
|
|
|
+ // before the engine sent those fields has neither, and falls straight
|
|
|
+ // through to the flat sortedNodes list below, unchanged from how it
|
|
|
+ // rendered previously.
|
|
|
+ const timeline = useMemo((): TimelineItem[] | null => {
|
|
|
+ const raw = executionState.rawNodeExecutions
|
|
|
+ if (!raw || raw.length === 0) return null
|
|
|
+ const hasLoopFields = raw.some(e => e.loopNodeId !== undefined && e.loopIteration !== undefined)
|
|
|
+ if (!hasLoopFields) return null
|
|
|
+
|
|
|
+ const groupsMap = new Map<string, LoopIterationGroup>()
|
|
|
+ const standaloneNodeIds = new Set<string>()
|
|
|
+ const items: TimelineItem[] = []
|
|
|
+
|
|
|
+ for (const exec of raw) {
|
|
|
+ if (exec.loopNodeId !== undefined && exec.loopIteration !== undefined) {
|
|
|
+ const key = `${exec.loopNodeId}#${exec.loopIteration}`
|
|
|
+ let group = groupsMap.get(key)
|
|
|
+ if (!group) {
|
|
|
+ group = {
|
|
|
+ key,
|
|
|
+ loopNodeId: exec.loopNodeId,
|
|
|
+ iteration: exec.loopIteration,
|
|
|
+ entries: [],
|
|
|
+ startedAt: exec.startedAt ?? 0,
|
|
|
+ completedAt: exec.finishedAt ?? 0,
|
|
|
+ hasFailure: false,
|
|
|
+ counts: { completed: 0, failed: 0, skipped: 0, disabled: 0 },
|
|
|
+ }
|
|
|
+ groupsMap.set(key, group)
|
|
|
+ items.push({ kind: 'group', startedAt: group.startedAt, group })
|
|
|
+ }
|
|
|
+ group.entries.push(exec)
|
|
|
+ if (exec.startedAt !== undefined) group.startedAt = Math.min(group.startedAt, exec.startedAt)
|
|
|
+ if (exec.finishedAt !== undefined) group.completedAt = Math.max(group.completedAt, exec.finishedAt)
|
|
|
+ const bucket = bucketStatus(exec.status)
|
|
|
+ group.counts[bucket]++
|
|
|
+ if (bucket === 'failed') group.hasFailure = true
|
|
|
+ } else if (!standaloneNodeIds.has(exec.nodeId)) {
|
|
|
+ // A node outside any loop appears once - use its recorded startedAt
|
|
|
+ // (falling back to the aggregated nodeStates entry) for ordering.
|
|
|
+ standaloneNodeIds.add(exec.nodeId)
|
|
|
+ items.push({
|
|
|
+ kind: 'node',
|
|
|
+ startedAt: exec.startedAt ?? executionState.nodeStates[exec.nodeId]?.startedAt ?? 0,
|
|
|
+ nodeId: exec.nodeId,
|
|
|
+ })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Kahn's algorithm for topological sort
|
|
|
- const queue: string[] = []
|
|
|
- const result: string[] = []
|
|
|
+ groupsMap.forEach(group => {
|
|
|
+ group.entries.sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0))
|
|
|
+ // Group start reflects the item's own position in the timeline, but
|
|
|
+ // items were pushed to `items` when the group was first seen, which
|
|
|
+ // already coincides with its earliest entry - the min() above just
|
|
|
+ // keeps `group.startedAt` itself accurate for the duration readout.
|
|
|
+ })
|
|
|
|
|
|
- // Start with nodes that have no incoming edges (triggers/start nodes)
|
|
|
- for (const nodeId of Object.keys(inDegree)) {
|
|
|
- if (inDegree[nodeId] === 0) {
|
|
|
- queue.push(nodeId)
|
|
|
+ items.sort((a, b) => a.startedAt - b.startedAt)
|
|
|
+ return items
|
|
|
+ }, [executionState.rawNodeExecutions, executionState.nodeStates])
|
|
|
+
|
|
|
+ // Overall status breakdown for the header. Only computed from raw records
|
|
|
+ // (accurate per-iteration counts); when they aren't available - a live
|
|
|
+ // run, or an execution predating this feature - the panel simply doesn't
|
|
|
+ // show a breakdown rather than risk a wrong one.
|
|
|
+ const statusCounts = useMemo(() => {
|
|
|
+ const raw = executionState.rawNodeExecutions
|
|
|
+ if (!raw || raw.length === 0) return null
|
|
|
+ const counts = { completed: 0, failed: 0, skipped: 0, disabled: 0 }
|
|
|
+ for (const exec of raw) counts[bucketStatus(exec.status)]++
|
|
|
+ return counts
|
|
|
+ }, [executionState.rawNodeExecutions])
|
|
|
+
|
|
|
+ // Shared row renderer for a single node's result, used both by the flat
|
|
|
+ // fallback list and by standalone (non-loop) entries in the grouped
|
|
|
+ // timeline. When `suppressIterationSelector` is set (the grouped timeline
|
|
|
+ // is active), a loop node's own row skips the old per-node iteration
|
|
|
+ // stepper - the iteration groups below it are now how you browse passes.
|
|
|
+ function renderNodeRow(node: Node, suppressIterationSelector: boolean) {
|
|
|
+ const nodeState = executionState.nodeStates[node.id]
|
|
|
+ if (!nodeState) return null
|
|
|
+ const isExpanded = expandedNodes.has(node.id)
|
|
|
+ const isLoopNode = node.data.type === 'loop'
|
|
|
+ const loopIterCount = loopIterationCounts.get(node.id) || 0
|
|
|
+ const parentLoopId = nodeToLoopMap.get(node.id)
|
|
|
+ const isLoopBodyNode = !!parentLoopId
|
|
|
+
|
|
|
+ const getSelectedIteration = () => {
|
|
|
+ if (isLoopBodyNode && parentLoopId) {
|
|
|
+ return selectedIterations[parentLoopId] ?? 0
|
|
|
}
|
|
|
+ return selectedIterations[node.id] ?? 0
|
|
|
}
|
|
|
|
|
|
- while (queue.length > 0) {
|
|
|
- // Sort queue by startedAt timestamp to break ties (nodes at same level)
|
|
|
- queue.sort((a, b) => {
|
|
|
- const timeA = executionState.nodeStates[a]?.startedAt ?? Infinity
|
|
|
- const timeB = executionState.nodeStates[b]?.startedAt ?? Infinity
|
|
|
- return timeA - timeB
|
|
|
- })
|
|
|
+ return (
|
|
|
+ <div key={node.id} className="p-3">
|
|
|
+ <button
|
|
|
+ onClick={() => onToggleNodeExpansion(node.id)}
|
|
|
+ className="w-full flex items-center gap-2 text-left"
|
|
|
+ >
|
|
|
+ {isExpanded ? (
|
|
|
+ <ChevronDown className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
|
+ ) : (
|
|
|
+ <ChevronRight className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
|
+ )}
|
|
|
|
|
|
- const nodeId = queue.shift()!
|
|
|
- result.push(nodeId)
|
|
|
+ <StatusIcon status={nodeState.status} />
|
|
|
|
|
|
- for (const neighbor of adjacencyList[nodeId] || []) {
|
|
|
- inDegree[neighbor]--
|
|
|
- if (inDegree[neighbor] === 0) {
|
|
|
- queue.push(neighbor)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate block">
|
|
|
+ {node.data.config?._customLabel || node.data.label}
|
|
|
+ </span>
|
|
|
+ {node.data.config?._customLabel && (
|
|
|
+ <span className="text-xs text-gray-500 dark:text-gray-400 truncate block">
|
|
|
+ {node.data.label} ({node.data.type})
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
|
|
|
- // Map back to node objects
|
|
|
- const nodeMap = new Map(executedNodes.map(n => [n.id, n]))
|
|
|
- const sortedResult = result.map(id => nodeMap.get(id)!).filter(Boolean)
|
|
|
-
|
|
|
- // If topological sort didn't include all nodes (cycles or disconnected),
|
|
|
- // fall back to timestamp order for remaining nodes
|
|
|
- if (sortedResult.length < executedNodes.length) {
|
|
|
- const included = new Set(result)
|
|
|
- const remaining = executedNodes
|
|
|
- .filter(n => !included.has(n.id))
|
|
|
- .sort((a, b) => {
|
|
|
- const timeA = executionState.nodeStates[a.id]?.startedAt ?? Infinity
|
|
|
- const timeB = executionState.nodeStates[b.id]?.startedAt ?? Infinity
|
|
|
- return timeA - timeB
|
|
|
- })
|
|
|
- return [...sortedResult, ...remaining]
|
|
|
- }
|
|
|
+ {/* Iteration count badge for loop nodes */}
|
|
|
+ {isLoopNode && loopIterCount > 0 && (
|
|
|
+ <span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs font-medium flex items-center gap-1">
|
|
|
+ <Repeat className="w-3 h-3" />
|
|
|
+ {loopIterCount}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
|
|
|
- return sortedResult
|
|
|
- }, [nodes, edges, executionState.nodeStates])
|
|
|
+ {/* Show iteration indicator for body nodes (flat fallback only) */}
|
|
|
+ {isLoopBodyNode && nodeState.iterations && nodeState.iterations.length > 0 && (
|
|
|
+ <span className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 rounded text-xs">
|
|
|
+ #{getSelectedIteration() + 1}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {nodeState.startedAt && nodeState.completedAt && (
|
|
|
+ <span className="text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ {((nodeState.completedAt - nodeState.startedAt) / 1000).toFixed(2)}s
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ {isExpanded && (
|
|
|
+ <div className="mt-2 ml-6">
|
|
|
+ {/* Iteration selector on LOOP nodes only, and only in the flat
|
|
|
+ fallback - the grouped timeline replaces this with real
|
|
|
+ per-iteration cards further down the list. */}
|
|
|
+ {isLoopNode && loopIterCount > 0 && !suppressIterationSelector && (
|
|
|
+ <div className="mb-3 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-xs text-blue-700 dark:text-blue-300 font-medium">View iteration:</span>
|
|
|
+ <div className="flex items-center gap-1">
|
|
|
+ <button
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ const current = selectedIterations[node.id] ?? 0
|
|
|
+ if (current > 0) {
|
|
|
+ onSelectIteration(node.id, current - 1)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ disabled={(selectedIterations[node.id] ?? 0) === 0}
|
|
|
+ className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ >
|
|
|
+ ←
|
|
|
+ </button>
|
|
|
+ <select
|
|
|
+ value={selectedIterations[node.id] ?? 0}
|
|
|
+ onChange={(e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ onSelectIteration(node.id, parseInt(e.target.value))
|
|
|
+ }}
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
+ className="px-2 py-0.5 text-xs border border-blue-300 dark:border-blue-700 rounded bg-white dark:bg-slate-800 text-blue-900 dark:text-blue-100"
|
|
|
+ >
|
|
|
+ {Array.from({ length: loopIterCount }, (_, idx) => (
|
|
|
+ <option key={idx} value={idx}>
|
|
|
+ {idx + 1} / {loopIterCount}
|
|
|
+ </option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ <button
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ const current = selectedIterations[node.id] ?? 0
|
|
|
+ if (current < loopIterCount - 1) {
|
|
|
+ onSelectIteration(node.id, current + 1)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ disabled={(selectedIterations[node.id] ?? 0) >= loopIterCount - 1}
|
|
|
+ className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ >
|
|
|
+ →
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
|
|
+ Changing iteration updates all nodes in this loop
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Body node iteration output - use parent loop's selected iteration (flat fallback only) */}
|
|
|
+ {isLoopBodyNode && nodeState.iterations && nodeState.iterations.length > 0 ? (
|
|
|
+ <div className="space-y-2">
|
|
|
+ {(() => {
|
|
|
+ const selectedIdx = getSelectedIteration()
|
|
|
+ const selectedIter = nodeState.iterations[selectedIdx]
|
|
|
+ if (!selectedIter) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ {selectedIter.status === 'disabled' ? (
|
|
|
+ <div className="text-xs text-gray-400 dark:text-gray-500 italic">Disabled: this node was switched off</div>
|
|
|
+ ) : selectedIter.error ? (
|
|
|
+ <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
|
|
|
+ {selectedIter.error}
|
|
|
+ </div>
|
|
|
+ ) : selectedIter.output !== undefined ? (
|
|
|
+ <NodeOutputView value={selectedIter.output} />
|
|
|
+ ) : selectedIter.status === 'skipped' ? (
|
|
|
+ <div className="text-xs text-gray-400 dark:text-gray-500 italic">Skipped (condition not met)</div>
|
|
|
+ ) : (
|
|
|
+ <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ })()}
|
|
|
+ </div>
|
|
|
+ ) : isLoopNode && loopIterCount > 0 && nodeState.output?.items ? (
|
|
|
+ /* Loop node with items - show selected item */
|
|
|
+ <div className="space-y-2">
|
|
|
+ {(() => {
|
|
|
+ const selectedIdx = selectedIterations[node.id] ?? 0
|
|
|
+ const selectedItem = nodeState.output.items[selectedIdx]
|
|
|
+ if (!selectedItem) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
|
|
|
+
|
|
|
+ return (
|
|
|
+ <NodeOutputView value={selectedItem} />
|
|
|
+ )
|
|
|
+ })()}
|
|
|
+ </div>
|
|
|
+ ) : nodeState.status === 'disabled' ? (
|
|
|
+ <div className="text-xs text-gray-400 dark:text-gray-500 italic">Disabled: this node was switched off and did not run</div>
|
|
|
+ ) : nodeState.error ? (
|
|
|
+ <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
|
|
|
+ {nodeState.error}
|
|
|
+ </div>
|
|
|
+ ) : nodeState.output !== undefined ? (
|
|
|
+ <NodeOutputView value={nodeState.output} />
|
|
|
+ ) : (
|
|
|
+ <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // A single node's result row inside an expanded iteration group. Simpler
|
|
|
+ // than renderNodeRow: no loop-of-its-own concerns, since a node that owns
|
|
|
+ // a loop cannot itself be a member of another iteration in this data.
|
|
|
+ function renderGroupEntry(exec: NodeExecution, group: LoopIterationGroup, idx: number) {
|
|
|
+ const node = nodes.find(n => n.id === exec.nodeId)
|
|
|
+ const label = node?.data.config?._customLabel || node?.data.label || exec.nodeId
|
|
|
+ const rowKey = `iter:${group.key}:${exec.nodeId}:${idx}`
|
|
|
+ const isExpanded = expandedNodes.has(rowKey)
|
|
|
+ const status = bucketStatus(exec.status)
|
|
|
+ const hasBody = status !== 'disabled' && (exec.error || exec.output !== undefined)
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div key={rowKey} className="py-1.5">
|
|
|
+ <button
|
|
|
+ onClick={() => hasBody && onToggleNodeExpansion(rowKey)}
|
|
|
+ className={`w-full flex items-center gap-2 text-left ${hasBody ? '' : 'cursor-default'}`}
|
|
|
+ >
|
|
|
+ {hasBody ? (
|
|
|
+ isExpanded ? (
|
|
|
+ <ChevronDown className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 shrink-0" />
|
|
|
+ ) : (
|
|
|
+ <ChevronRight className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 shrink-0" />
|
|
|
+ )
|
|
|
+ ) : (
|
|
|
+ <span className="w-3.5 h-3.5 shrink-0" />
|
|
|
+ )}
|
|
|
+ <StatusIcon status={exec.status} />
|
|
|
+ <span className="flex-1 min-w-0 text-xs text-gray-800 dark:text-gray-200 truncate">
|
|
|
+ {label}
|
|
|
+ </span>
|
|
|
+ {exec.startedAt !== undefined && exec.finishedAt !== undefined && (
|
|
|
+ <span className="text-[10px] text-gray-400 dark:text-gray-500">
|
|
|
+ {((exec.finishedAt - exec.startedAt) / 1000).toFixed(2)}s
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ {hasBody && isExpanded && (
|
|
|
+ <div className="mt-1 ml-5">
|
|
|
+ {exec.error ? (
|
|
|
+ <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
|
|
|
+ {exec.error}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <NodeOutputView value={exec.output} />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGroup(group: LoopIterationGroup) {
|
|
|
+ // Failed iterations open by default, so a bad pass is visible without
|
|
|
+ // hunting through however many succeeded around it. Toggling flips that
|
|
|
+ // default per group.
|
|
|
+ const isExpanded = toggledGroups.has(group.key) ? !group.hasFailure : group.hasFailure
|
|
|
+ const loopNode = nodes.find(n => n.id === group.loopNodeId)
|
|
|
+ const loopLabel = loopNode?.data.config?._customLabel || loopNode?.data.label || group.loopNodeId
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div key={group.key} className={`p-3 ${group.hasFailure ? 'bg-red-50/50 dark:bg-red-900/10' : ''}`}>
|
|
|
+ <button
|
|
|
+ onClick={() => toggleGroup(group.key)}
|
|
|
+ className="w-full flex items-center gap-2 text-left"
|
|
|
+ >
|
|
|
+ {isExpanded ? (
|
|
|
+ <ChevronDown className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
|
+ ) : (
|
|
|
+ <ChevronRight className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
|
+ )}
|
|
|
+ <Repeat className="w-4 h-4 text-blue-500 shrink-0" />
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate block">
|
|
|
+ {loopLabel} - iteration {group.iteration + 1}
|
|
|
+ </span>
|
|
|
+ <span className="text-xs text-gray-500 dark:text-gray-400 truncate block">
|
|
|
+ {group.entries.length} node{group.entries.length === 1 ? '' : 's'} - {countsLabel(group.counts)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ {group.hasFailure && (
|
|
|
+ <span className="px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded text-xs font-medium flex items-center gap-1 shrink-0">
|
|
|
+ <XCircle className="w-3 h-3" />
|
|
|
+ failed
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ {group.startedAt && group.completedAt ? (
|
|
|
+ <span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
|
|
+ {((group.completedAt - group.startedAt) / 1000).toFixed(2)}s
|
|
|
+ </span>
|
|
|
+ ) : null}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ {isExpanded && (
|
|
|
+ <div className="mt-2 ml-6 divide-y divide-gray-100 dark:divide-slate-700">
|
|
|
+ {group.entries.map((exec, idx) => renderGroupEntry(exec, group, idx))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const hasAnyResults = timeline
|
|
|
+ ? timeline.length > 0
|
|
|
+ : Object.keys(executionState.nodeStates).length > 0
|
|
|
|
|
|
return (
|
|
|
<div
|
|
|
@@ -232,199 +621,37 @@ export function ExecutionResultsPanel({
|
|
|
Duration: {((executionState.completedAt - executionState.startedAt) / 1000).toFixed(2)}s
|
|
|
</div>
|
|
|
)}
|
|
|
+ {/* Ran / failed / skipped / disabled, kept as separate buckets on
|
|
|
+ purpose - a disabled node never ran, so it never counts toward
|
|
|
+ "completed", the same way a skipped branch doesn't either. */}
|
|
|
+ {statusCounts && (
|
|
|
+ <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
|
+ {countsLabel(statusCounts)}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
|
|
|
- {/* Node results - sorted by topological order (workflow connections) */}
|
|
|
+ {/* Node results, in the order they actually ran. Loop bodies collapse
|
|
|
+ into one card per iteration when the execution has loopNodeId /
|
|
|
+ loopIteration on its records; otherwise this is the same flat,
|
|
|
+ startedAt-ordered list as before. */}
|
|
|
<div
|
|
|
className="flex-1 overflow-y-auto divide-y divide-gray-200 dark:divide-slate-700"
|
|
|
style={{ overscrollBehavior: 'contain' }}
|
|
|
onWheel={(e) => e.stopPropagation()}
|
|
|
>
|
|
|
- {sortedNodes.map((node) => {
|
|
|
- const nodeState = executionState.nodeStates[node.id]
|
|
|
- const isExpanded = expandedNodes.has(node.id)
|
|
|
- const isLoopNode = node.data.type === 'loop'
|
|
|
- const loopIterCount = loopIterationCounts.get(node.id) || 0
|
|
|
- const parentLoopId = nodeToLoopMap.get(node.id)
|
|
|
- const isLoopBodyNode = !!parentLoopId
|
|
|
-
|
|
|
- // Get the selected iteration index - for body nodes, use parent loop's selection
|
|
|
- const getSelectedIteration = () => {
|
|
|
- if (isLoopBodyNode && parentLoopId) {
|
|
|
- return selectedIterations[parentLoopId] ?? 0
|
|
|
- }
|
|
|
- return selectedIterations[node.id] ?? 0
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <div key={node.id} className="p-3">
|
|
|
- <button
|
|
|
- onClick={() => onToggleNodeExpansion(node.id)}
|
|
|
- className="w-full flex items-center gap-2 text-left"
|
|
|
- >
|
|
|
- {isExpanded ? (
|
|
|
- <ChevronDown className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
|
- ) : (
|
|
|
- <ChevronRight className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
|
- )}
|
|
|
-
|
|
|
- {nodeState.status === 'running' && (
|
|
|
- <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
|
|
|
- )}
|
|
|
- {nodeState.status === 'completed' && (
|
|
|
- <CheckCircle className="w-4 h-4 text-green-500" />
|
|
|
- )}
|
|
|
- {nodeState.status === 'failed' && (
|
|
|
- <XCircle className="w-4 h-4 text-red-500" />
|
|
|
- )}
|
|
|
- {nodeState.status === 'skipped' && (
|
|
|
- <span className="w-4 h-4 text-gray-400 dark:text-gray-500 flex items-center justify-center text-xs">⊘</span>
|
|
|
- )}
|
|
|
-
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
- <span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate block">
|
|
|
- {node.data.config?._customLabel || node.data.label}
|
|
|
- </span>
|
|
|
- {node.data.config?._customLabel && (
|
|
|
- <span className="text-xs text-gray-500 dark:text-gray-400 truncate block">
|
|
|
- {node.data.label} ({node.data.type})
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Iteration count badge for loop nodes */}
|
|
|
- {isLoopNode && loopIterCount > 0 && (
|
|
|
- <span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs font-medium flex items-center gap-1">
|
|
|
- <Repeat className="w-3 h-3" />
|
|
|
- {loopIterCount}
|
|
|
- </span>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Show iteration indicator for body nodes */}
|
|
|
- {isLoopBodyNode && nodeState.iterations && nodeState.iterations.length > 0 && (
|
|
|
- <span className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 rounded text-xs">
|
|
|
- #{getSelectedIteration() + 1}
|
|
|
- </span>
|
|
|
- )}
|
|
|
-
|
|
|
- {nodeState.startedAt && nodeState.completedAt && (
|
|
|
- <span className="text-xs text-gray-400 dark:text-gray-500">
|
|
|
- {((nodeState.completedAt - nodeState.startedAt) / 1000).toFixed(2)}s
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </button>
|
|
|
-
|
|
|
- {isExpanded && (
|
|
|
- <div className="mt-2 ml-6">
|
|
|
- {/* Iteration selector on LOOP nodes only */}
|
|
|
- {isLoopNode && loopIterCount > 0 && (
|
|
|
- <div className="mb-3 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <span className="text-xs text-blue-700 dark:text-blue-300 font-medium">View iteration:</span>
|
|
|
- <div className="flex items-center gap-1">
|
|
|
- <button
|
|
|
- onClick={(e) => {
|
|
|
- e.stopPropagation()
|
|
|
- const current = selectedIterations[node.id] ?? 0
|
|
|
- if (current > 0) {
|
|
|
- onSelectIteration(node.id, current - 1)
|
|
|
- }
|
|
|
- }}
|
|
|
- disabled={(selectedIterations[node.id] ?? 0) === 0}
|
|
|
- className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
- >
|
|
|
- ←
|
|
|
- </button>
|
|
|
- <select
|
|
|
- value={selectedIterations[node.id] ?? 0}
|
|
|
- onChange={(e) => {
|
|
|
- e.stopPropagation()
|
|
|
- onSelectIteration(node.id, parseInt(e.target.value))
|
|
|
- }}
|
|
|
- onClick={(e) => e.stopPropagation()}
|
|
|
- className="px-2 py-0.5 text-xs border border-blue-300 dark:border-blue-700 rounded bg-white dark:bg-slate-800 text-blue-900 dark:text-blue-100"
|
|
|
- >
|
|
|
- {Array.from({ length: loopIterCount }, (_, idx) => (
|
|
|
- <option key={idx} value={idx}>
|
|
|
- {idx + 1} / {loopIterCount}
|
|
|
- </option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
- <button
|
|
|
- onClick={(e) => {
|
|
|
- e.stopPropagation()
|
|
|
- const current = selectedIterations[node.id] ?? 0
|
|
|
- if (current < loopIterCount - 1) {
|
|
|
- onSelectIteration(node.id, current + 1)
|
|
|
- }
|
|
|
- }}
|
|
|
- disabled={(selectedIterations[node.id] ?? 0) >= loopIterCount - 1}
|
|
|
- className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200 rounded hover:bg-blue-200 dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
- >
|
|
|
- →
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
|
|
- Changing iteration updates all nodes in this loop
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Body node iteration output - use parent loop's selected iteration */}
|
|
|
- {isLoopBodyNode && nodeState.iterations && nodeState.iterations.length > 0 ? (
|
|
|
- <div className="space-y-2">
|
|
|
- {(() => {
|
|
|
- const selectedIdx = getSelectedIteration()
|
|
|
- const selectedIter = nodeState.iterations[selectedIdx]
|
|
|
- if (!selectedIter) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
|
|
|
-
|
|
|
- return (
|
|
|
- <div>
|
|
|
- {selectedIter.error ? (
|
|
|
- <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
|
|
|
- {selectedIter.error}
|
|
|
- </div>
|
|
|
- ) : selectedIter.output !== undefined ? (
|
|
|
- <NodeOutputView value={selectedIter.output} />
|
|
|
- ) : selectedIter.status === 'skipped' ? (
|
|
|
- <div className="text-xs text-gray-400 dark:text-gray-500 italic">Skipped (condition not met)</div>
|
|
|
- ) : (
|
|
|
- <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- )
|
|
|
- })()}
|
|
|
- </div>
|
|
|
- ) : isLoopNode && loopIterCount > 0 && nodeState.output?.items ? (
|
|
|
- /* Loop node with items - show selected item */
|
|
|
- <div className="space-y-2">
|
|
|
- {(() => {
|
|
|
- const selectedIdx = selectedIterations[node.id] ?? 0
|
|
|
- const selectedItem = nodeState.output.items[selectedIdx]
|
|
|
- if (!selectedItem) return <div className="text-xs text-gray-400 dark:text-gray-500 italic">No data for this iteration</div>
|
|
|
-
|
|
|
- return (
|
|
|
- <NodeOutputView value={selectedItem} />
|
|
|
- )
|
|
|
- })()}
|
|
|
- </div>
|
|
|
- ) : nodeState.error ? (
|
|
|
- <div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400 font-mono">
|
|
|
- {nodeState.error}
|
|
|
- </div>
|
|
|
- ) : nodeState.output !== undefined ? (
|
|
|
- <NodeOutputView value={nodeState.output} />
|
|
|
- ) : (
|
|
|
- <div className="text-xs text-gray-400 dark:text-gray-500 italic">No output</div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- )
|
|
|
- })}
|
|
|
-
|
|
|
- {Object.keys(executionState.nodeStates).length === 0 && (
|
|
|
+ {timeline
|
|
|
+ ? timeline.map(item =>
|
|
|
+ item.kind === 'group'
|
|
|
+ ? renderGroup(item.group)
|
|
|
+ : (() => {
|
|
|
+ const node = nodes.find(n => n.id === item.nodeId)
|
|
|
+ return node ? renderNodeRow(node, true) : null
|
|
|
+ })()
|
|
|
+ )
|
|
|
+ : sortedNodes.map((node) => renderNodeRow(node, false))}
|
|
|
+
|
|
|
+ {!hasAnyResults && (
|
|
|
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
|
{executionState.status === 'running'
|
|
|
? 'Waiting for execution results...'
|