|
@@ -243,9 +243,9 @@ function WorkflowEditorInner() {
|
|
|
|
|
|
|
|
// Handle viewing an execution in read-only mode
|
|
// Handle viewing an execution in read-only mode
|
|
|
const handleViewExecution = useCallback((execution: ExecutionDetail) => {
|
|
const handleViewExecution = useCallback((execution: ExecutionDetail) => {
|
|
|
- setViewingExecution(true)
|
|
|
|
|
const { currentNodes, currentConnections } = getCurrentWorkflowData()
|
|
const { currentNodes, currentConnections } = getCurrentWorkflowData()
|
|
|
pinExecution(execution, currentNodes, currentConnections)
|
|
pinExecution(execution, currentNodes, currentConnections)
|
|
|
|
|
+ setViewingExecution(true) // Must be called AFTER pinExecution (which resets it to false)
|
|
|
setShowExecutionList(false)
|
|
setShowExecutionList(false)
|
|
|
showToast('info', 'Viewing execution results')
|
|
showToast('info', 'Viewing execution results')
|
|
|
}, [getCurrentWorkflowData, pinExecution, setViewingExecution, showToast])
|
|
}, [getCurrentWorkflowData, pinExecution, setViewingExecution, showToast])
|
|
@@ -328,6 +328,9 @@ function WorkflowEditorInner() {
|
|
|
const executeWorkflow = useCallback((triggerNodeId?: string) => {
|
|
const executeWorkflow = useCallback((triggerNodeId?: string) => {
|
|
|
if (!id) return
|
|
if (!id) return
|
|
|
|
|
|
|
|
|
|
+ // Set pending marker BEFORE HTTP request to capture early WebSocket events
|
|
|
|
|
+ pendingExecIdRef.current = 'pending'
|
|
|
|
|
+
|
|
|
setExecutionState({
|
|
setExecutionState({
|
|
|
executionId: null,
|
|
executionId: null,
|
|
|
status: 'running',
|
|
status: 'running',
|
|
@@ -346,6 +349,7 @@ function WorkflowEditorInner() {
|
|
|
}))
|
|
}))
|
|
|
showToast('info', `Execution started: ${result.executionId}`)
|
|
showToast('info', `Execution started: ${result.executionId}`)
|
|
|
}).catch((error) => {
|
|
}).catch((error) => {
|
|
|
|
|
+ pendingExecIdRef.current = null
|
|
|
setExecutionState((prev) => ({
|
|
setExecutionState((prev) => ({
|
|
|
...prev,
|
|
...prev,
|
|
|
status: 'failed',
|
|
status: 'failed',
|
|
@@ -364,6 +368,9 @@ function WorkflowEditorInner() {
|
|
|
const executeNode = useCallback((targetNodeId: string) => {
|
|
const executeNode = useCallback((targetNodeId: string) => {
|
|
|
if (!id) return
|
|
if (!id) return
|
|
|
|
|
|
|
|
|
|
+ // Set pending marker BEFORE HTTP request to capture early WebSocket events
|
|
|
|
|
+ pendingExecIdRef.current = 'pending'
|
|
|
|
|
+
|
|
|
setExecutingNodeId(targetNodeId)
|
|
setExecutingNodeId(targetNodeId)
|
|
|
setExecutionState({
|
|
setExecutionState({
|
|
|
executionId: null,
|
|
executionId: null,
|
|
@@ -383,6 +390,7 @@ function WorkflowEditorInner() {
|
|
|
}))
|
|
}))
|
|
|
showToast('info', `Running node: ${targetNodeId}`)
|
|
showToast('info', `Running node: ${targetNodeId}`)
|
|
|
}).catch((error) => {
|
|
}).catch((error) => {
|
|
|
|
|
+ pendingExecIdRef.current = null
|
|
|
setExecutionState((prev) => ({
|
|
setExecutionState((prev) => ({
|
|
|
...prev,
|
|
...prev,
|
|
|
status: 'failed',
|
|
status: 'failed',
|
|
@@ -393,10 +401,8 @@ function WorkflowEditorInner() {
|
|
|
})
|
|
})
|
|
|
}, [id, showToast])
|
|
}, [id, showToast])
|
|
|
|
|
|
|
|
- // WebSocket subscription for execution updates
|
|
|
|
|
|
|
+ // WebSocket subscription for execution updates - subscribe on mount, filter in handler
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (executionState.status !== 'running') return
|
|
|
|
|
-
|
|
|
|
|
const handleExecutionEvent = (data: any) => {
|
|
const handleExecutionEvent = (data: any) => {
|
|
|
const channel = data._channel || ''
|
|
const channel = data._channel || ''
|
|
|
const parts = channel.split('.')
|
|
const parts = channel.split('.')
|
|
@@ -405,11 +411,15 @@ function WorkflowEditorInner() {
|
|
|
const eventExecId = parts[1]
|
|
const eventExecId = parts[1]
|
|
|
const eventType = parts.slice(2).join('.')
|
|
const eventType = parts.slice(2).join('.')
|
|
|
|
|
|
|
|
- const targetExecId = executionState.executionId || pendingExecIdRef.current
|
|
|
|
|
- if (targetExecId && eventExecId !== targetExecId) return
|
|
|
|
|
|
|
+ // Only process events when we're running or have a pending execution
|
|
|
|
|
+ const targetExecId = pendingExecIdRef.current
|
|
|
|
|
+ if (!targetExecId) return // Not running any execution
|
|
|
|
|
|
|
|
- if (!executionState.executionId && !pendingExecIdRef.current) {
|
|
|
|
|
|
|
+ // If we're in 'pending' state, accept first event and capture execution ID
|
|
|
|
|
+ if (targetExecId === 'pending') {
|
|
|
pendingExecIdRef.current = eventExecId
|
|
pendingExecIdRef.current = eventExecId
|
|
|
|
|
+ } else if (eventExecId !== targetExecId) {
|
|
|
|
|
+ return // Different execution
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (eventType === 'node_running') {
|
|
if (eventType === 'node_running') {
|
|
@@ -459,19 +469,30 @@ function WorkflowEditorInner() {
|
|
|
}))
|
|
}))
|
|
|
setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
|
|
setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
|
|
|
} else if (eventType === 'node_skipped') {
|
|
} else if (eventType === 'node_skipped') {
|
|
|
- setExecutionState((prev) => ({
|
|
|
|
|
- ...prev,
|
|
|
|
|
- executionId: eventExecId,
|
|
|
|
|
- nodeStates: {
|
|
|
|
|
- ...prev.nodeStates,
|
|
|
|
|
- [data.nodeId]: {
|
|
|
|
|
- status: 'skipped',
|
|
|
|
|
- output: { reason: data.reason || 'Inactive branch' },
|
|
|
|
|
- completedAt: Date.now(),
|
|
|
|
|
|
|
+ setExecutionState((prev) => {
|
|
|
|
|
+ const existingState = prev.nodeStates[data.nodeId]
|
|
|
|
|
+ // Don't overwrite if this node already has meaningful execution state
|
|
|
|
|
+ // (iterations from loop, or completed/failed status)
|
|
|
|
|
+ if (existingState?.iterations && existingState.iterations.length > 0) {
|
|
|
|
|
+ return prev
|
|
|
|
|
+ }
|
|
|
|
|
+ if (existingState?.status === 'completed' || existingState?.status === 'failed') {
|
|
|
|
|
+ return prev
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...prev,
|
|
|
|
|
+ executionId: eventExecId,
|
|
|
|
|
+ nodeStates: {
|
|
|
|
|
+ ...prev.nodeStates,
|
|
|
|
|
+ [data.nodeId]: {
|
|
|
|
|
+ status: 'skipped',
|
|
|
|
|
+ output: { reason: data.reason || 'Inactive branch' },
|
|
|
|
|
+ completedAt: Date.now(),
|
|
|
|
|
+ },
|
|
|
},
|
|
},
|
|
|
- },
|
|
|
|
|
- }))
|
|
|
|
|
- } else if (eventType === 'loop_node_running') {
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } else if (eventType === 'loop_node_running' || eventType === 'loop.node.running') {
|
|
|
setExecutionState((prev) => {
|
|
setExecutionState((prev) => {
|
|
|
const prevNodeState = prev.nodeStates[data.nodeId] || {}
|
|
const prevNodeState = prev.nodeStates[data.nodeId] || {}
|
|
|
return {
|
|
return {
|
|
@@ -488,7 +509,7 @@ function WorkflowEditorInner() {
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
- } else if (eventType === 'loop_node_completed') {
|
|
|
|
|
|
|
+ } else if (eventType === 'loop_node_completed' || eventType === 'loop.node.completed') {
|
|
|
setExecutionState((prev) => {
|
|
setExecutionState((prev) => {
|
|
|
const prevNodeState = prev.nodeStates[data.nodeId] || {}
|
|
const prevNodeState = prev.nodeStates[data.nodeId] || {}
|
|
|
const prevIterations = prevNodeState.iterations || []
|
|
const prevIterations = prevNodeState.iterations || []
|
|
@@ -518,7 +539,7 @@ function WorkflowEditorInner() {
|
|
|
...prev,
|
|
...prev,
|
|
|
[data.nodeId]: data.output,
|
|
[data.nodeId]: data.output,
|
|
|
}))
|
|
}))
|
|
|
- } else if (eventType === 'loop_node_failed') {
|
|
|
|
|
|
|
+ } else if (eventType === 'loop_node_failed' || eventType === 'loop.node.failed') {
|
|
|
setExecutionState((prev) => {
|
|
setExecutionState((prev) => {
|
|
|
const prevNodeState = prev.nodeStates[data.nodeId] || {}
|
|
const prevNodeState = prev.nodeStates[data.nodeId] || {}
|
|
|
const prevIterations = prevNodeState.iterations || []
|
|
const prevIterations = prevNodeState.iterations || []
|
|
@@ -544,7 +565,32 @@ function WorkflowEditorInner() {
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
|
|
setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
|
|
|
- } else if (eventType === 'loop_iteration_start') {
|
|
|
|
|
|
|
+ } else if (eventType === 'loop_node_skipped' || eventType === 'loop.node.skipped') {
|
|
|
|
|
+ // Handle skipped iterations in loop body nodes
|
|
|
|
|
+ setExecutionState((prev) => {
|
|
|
|
|
+ const prevNodeState = prev.nodeStates[data.nodeId] || {}
|
|
|
|
|
+ const prevIterations = prevNodeState.iterations || []
|
|
|
|
|
+ const newIteration: IterationResult = {
|
|
|
|
|
+ index: data.iteration,
|
|
|
|
|
+ status: 'skipped',
|
|
|
|
|
+ output: data.output,
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...prev,
|
|
|
|
|
+ executionId: eventExecId,
|
|
|
|
|
+ nodeStates: {
|
|
|
|
|
+ ...prev.nodeStates,
|
|
|
|
|
+ [data.nodeId]: {
|
|
|
|
|
+ ...prevNodeState,
|
|
|
|
|
+ status: 'completed', // Overall status stays completed (skipped iteration is normal)
|
|
|
|
|
+ completedAt: Date.now(),
|
|
|
|
|
+ iteration: data.iteration,
|
|
|
|
|
+ iterations: [...prevIterations, newIteration],
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } else if (eventType === 'loop_iteration_start' || eventType === 'loop.iteration.start') {
|
|
|
setExecutionState((prev) => ({
|
|
setExecutionState((prev) => ({
|
|
|
...prev,
|
|
...prev,
|
|
|
executionId: eventExecId,
|
|
executionId: eventExecId,
|
|
@@ -583,6 +629,7 @@ function WorkflowEditorInner() {
|
|
|
}))
|
|
}))
|
|
|
setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
|
|
setExpandedNodes((prev) => new Set([...prev, data.nodeId]))
|
|
|
} else if (eventType === 'completed') {
|
|
} else if (eventType === 'completed') {
|
|
|
|
|
+ pendingExecIdRef.current = null // Clear pending marker
|
|
|
setExecutionState((prev) => ({
|
|
setExecutionState((prev) => ({
|
|
|
...prev,
|
|
...prev,
|
|
|
executionId: eventExecId,
|
|
executionId: eventExecId,
|
|
@@ -592,6 +639,7 @@ function WorkflowEditorInner() {
|
|
|
setExecutingNodeId(null)
|
|
setExecutingNodeId(null)
|
|
|
showToast('success', 'Workflow execution completed')
|
|
showToast('success', 'Workflow execution completed')
|
|
|
} else if (eventType === 'failed') {
|
|
} else if (eventType === 'failed') {
|
|
|
|
|
+ pendingExecIdRef.current = null // Clear pending marker
|
|
|
setExecutionState((prev) => ({
|
|
setExecutionState((prev) => ({
|
|
|
...prev,
|
|
...prev,
|
|
|
executionId: eventExecId,
|
|
executionId: eventExecId,
|
|
@@ -614,9 +662,8 @@ function WorkflowEditorInner() {
|
|
|
return () => {
|
|
return () => {
|
|
|
wsClient.unsubscribe(['executions.*'])
|
|
wsClient.unsubscribe(['executions.*'])
|
|
|
wsClient.off('executions.*', handleExecutionEvent)
|
|
wsClient.off('executions.*', handleExecutionEvent)
|
|
|
- pendingExecIdRef.current = null
|
|
|
|
|
}
|
|
}
|
|
|
- }, [executionState.status, executionState.executionId, showToast])
|
|
|
|
|
|
|
+ }, [showToast]) // Subscribe on mount, unsubscribe on unmount
|
|
|
|
|
|
|
|
const nodeDefs: NodeDefinition[] = nodeDefinitions?.nodes || []
|
|
const nodeDefs: NodeDefinition[] = nodeDefinitions?.nodes || []
|
|
|
const nodeDefsMap = useMemo(() => {
|
|
const nodeDefsMap = useMemo(() => {
|
|
@@ -625,6 +672,69 @@ function WorkflowEditorInner() {
|
|
|
return map
|
|
return map
|
|
|
}, [nodeDefs])
|
|
}, [nodeDefs])
|
|
|
|
|
|
|
|
|
|
+ // Compute viewed nodes and edges when in view mode (from workflow snapshot)
|
|
|
|
|
+ const { viewedNodes, viewedEdges } = useMemo(() => {
|
|
|
|
|
+ if (!isViewingExecution || !pinnedExecution?.workflowSnapshot) {
|
|
|
|
|
+ return { viewedNodes: null, viewedEdges: null }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Convert snapshot nodes to ReactFlow format
|
|
|
|
|
+ const snapshotNodes = pinnedExecution.workflowSnapshot.nodes.map(n => {
|
|
|
|
|
+ const nodeDef = nodeDefsMap[n.type]
|
|
|
|
|
+ const outputs = nodeDef?.outputs?.length
|
|
|
|
|
+ ? nodeDef.outputs
|
|
|
|
|
+ : [{ name: 'main', displayName: 'Output', type: 'any' }]
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: n.id,
|
|
|
|
|
+ type: 'workflowNode' as const,
|
|
|
|
|
+ position: n.position,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ label: n.name || n.type,
|
|
|
|
|
+ type: n.type,
|
|
|
|
|
+ config: n.config,
|
|
|
|
|
+ isTrigger: nodeDef?.isTrigger || false,
|
|
|
|
|
+ icon: nodeDef?.icon,
|
|
|
|
|
+ outputs,
|
|
|
|
|
+ nodeId: n.id,
|
|
|
|
|
+ onExecute: () => {}, // No-op in view mode
|
|
|
|
|
+ onExecuteTrigger: () => {}, // No-op in view mode
|
|
|
|
|
+ executionState: effectiveExecutionState.nodeStates[n.id],
|
|
|
|
|
+ },
|
|
|
|
|
+ selected: false,
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Convert snapshot connections to ReactFlow edges
|
|
|
|
|
+ const snapshotEdges = pinnedExecution.workflowSnapshot.connections.map((c, idx) => {
|
|
|
|
|
+ let edgeColor = '#cbd5e1'
|
|
|
|
|
+ if (c.sourceOutput === 'true' || c.sourceOutput === 'loop') {
|
|
|
|
|
+ edgeColor = '#22c55e'
|
|
|
|
|
+ } else if (c.sourceOutput === 'false') {
|
|
|
|
|
+ edgeColor = '#ef4444'
|
|
|
|
|
+ } else if (c.sourceOutput === 'done') {
|
|
|
|
|
+ edgeColor = '#3b82f6'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: `e${idx}`,
|
|
|
|
|
+ source: c.sourceNodeId,
|
|
|
|
|
+ sourceHandle: c.sourceOutput,
|
|
|
|
|
+ target: c.targetNodeId,
|
|
|
|
|
+ targetHandle: c.targetInput,
|
|
|
|
|
+ type: 'smoothstep' as const,
|
|
|
|
|
+ style: { strokeWidth: 2, stroke: edgeColor },
|
|
|
|
|
+ animated: false,
|
|
|
|
|
+ label: c.sourceOutput !== 'main' ? c.sourceOutput : undefined,
|
|
|
|
|
+ labelStyle: { fill: edgeColor, fontWeight: 500, fontSize: 10 },
|
|
|
|
|
+ labelBgStyle: { fill: 'var(--color-bg)', fillOpacity: 0.9 },
|
|
|
|
|
+ zIndex: 1,
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return { viewedNodes: snapshotNodes, viewedEdges: snapshotEdges }
|
|
|
|
|
+ }, [isViewingExecution, pinnedExecution, nodeDefsMap, effectiveExecutionState.nodeStates])
|
|
|
|
|
+
|
|
|
// Get list of trigger nodes for multi-trigger selection
|
|
// Get list of trigger nodes for multi-trigger selection
|
|
|
const triggerNodes = useMemo(() => {
|
|
const triggerNodes = useMemo(() => {
|
|
|
return nodes
|
|
return nodes
|
|
@@ -1284,12 +1394,24 @@ function WorkflowEditorInner() {
|
|
|
const fields: FieldInfo[] = []
|
|
const fields: FieldInfo[] = []
|
|
|
const visited = new Set<string>()
|
|
const visited = new Set<string>()
|
|
|
|
|
|
|
|
|
|
+ // Get direct predecessor node IDs and their targetHandle (target_input) from edges
|
|
|
|
|
+ const directUpstreamEdges = edges.filter(e => e.target === nodeId)
|
|
|
|
|
+ const directUpstreamIds = new Set(directUpstreamEdges.map(e => e.source))
|
|
|
|
|
+ // Map from source node ID to targetHandle (for expression path prefix)
|
|
|
|
|
+ const directUpstreamTargetInputs = new Map<string, string>()
|
|
|
|
|
+ for (const edge of directUpstreamEdges) {
|
|
|
|
|
+ // Use targetHandle if present, otherwise default to 'data'
|
|
|
|
|
+ directUpstreamTargetInputs.set(edge.source, edge.targetHandle || 'data')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const extractFieldsFromData = (
|
|
const extractFieldsFromData = (
|
|
|
data: any,
|
|
data: any,
|
|
|
prefix: string,
|
|
prefix: string,
|
|
|
nodeName: string,
|
|
nodeName: string,
|
|
|
nodeIdSource: string,
|
|
nodeIdSource: string,
|
|
|
- depth: number = 0
|
|
|
|
|
|
|
+ depth: number = 0,
|
|
|
|
|
+ isDirectUpstream: boolean = false,
|
|
|
|
|
+ targetInput?: string
|
|
|
): FieldInfo[] => {
|
|
): FieldInfo[] => {
|
|
|
const result: FieldInfo[] = []
|
|
const result: FieldInfo[] = []
|
|
|
if (data === null || data === undefined) return result
|
|
if (data === null || data === undefined) return result
|
|
@@ -1314,12 +1436,14 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: nodeIdSource,
|
|
nodeId: nodeIdSource,
|
|
|
displayName: `[${i}]`,
|
|
displayName: `[${i}]`,
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (fieldType === 'object' && item !== null) {
|
|
if (fieldType === 'object' && item !== null) {
|
|
|
- fieldInfo.children = extractFieldsFromData(item, path, nodeName, nodeIdSource, depth + 1)
|
|
|
|
|
|
|
+ fieldInfo.children = extractFieldsFromData(item, path, nodeName, nodeIdSource, depth + 1, isDirectUpstream, targetInput)
|
|
|
} else if (fieldType === 'array') {
|
|
} else if (fieldType === 'array') {
|
|
|
- fieldInfo.children = extractFieldsFromData(item, path, nodeName, nodeIdSource, depth + 1)
|
|
|
|
|
|
|
+ fieldInfo.children = extractFieldsFromData(item, path, nodeName, nodeIdSource, depth + 1, isDirectUpstream, targetInput)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
result.push(fieldInfo)
|
|
result.push(fieldInfo)
|
|
@@ -1331,6 +1455,8 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: nodeIdSource,
|
|
nodeId: nodeIdSource,
|
|
|
displayName: `... (${data.length} total items)`,
|
|
displayName: `... (${data.length} total items)`,
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
} else if (typeof data === 'object') {
|
|
} else if (typeof data === 'object') {
|
|
@@ -1352,12 +1478,14 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: nodeIdSource,
|
|
nodeId: nodeIdSource,
|
|
|
displayName: key,
|
|
displayName: key,
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (fieldType === 'object' && value !== null) {
|
|
if (fieldType === 'object' && value !== null) {
|
|
|
- fieldInfo.children = extractFieldsFromData(value, path, nodeName, nodeIdSource, depth + 1)
|
|
|
|
|
|
|
+ fieldInfo.children = extractFieldsFromData(value, path, nodeName, nodeIdSource, depth + 1, isDirectUpstream, targetInput)
|
|
|
} else if (fieldType === 'array' && value !== null) {
|
|
} else if (fieldType === 'array' && value !== null) {
|
|
|
- fieldInfo.children = extractFieldsFromData(value, path, nodeName, nodeIdSource, depth + 1)
|
|
|
|
|
|
|
+ fieldInfo.children = extractFieldsFromData(value, path, nodeName, nodeIdSource, depth + 1, isDirectUpstream, targetInput)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
result.push(fieldInfo)
|
|
result.push(fieldInfo)
|
|
@@ -1370,7 +1498,9 @@ function WorkflowEditorInner() {
|
|
|
props: Record<string, any>,
|
|
props: Record<string, any>,
|
|
|
prefix: string,
|
|
prefix: string,
|
|
|
nodeName: string,
|
|
nodeName: string,
|
|
|
- nodeIdSource: string
|
|
|
|
|
|
|
+ nodeIdSource: string,
|
|
|
|
|
+ isDirectUpstream: boolean = false,
|
|
|
|
|
+ targetInput?: string
|
|
|
): FieldInfo[] => {
|
|
): FieldInfo[] => {
|
|
|
const result: FieldInfo[] = []
|
|
const result: FieldInfo[] = []
|
|
|
for (const [key, value] of Object.entries(props)) {
|
|
for (const [key, value] of Object.entries(props)) {
|
|
@@ -1385,6 +1515,8 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: nodeIdSource,
|
|
nodeId: nodeIdSource,
|
|
|
displayName: (value as any).title || key,
|
|
displayName: (value as any).title || key,
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (fieldType === 'object' && (value as any).properties) {
|
|
if (fieldType === 'object' && (value as any).properties) {
|
|
@@ -1392,7 +1524,9 @@ function WorkflowEditorInner() {
|
|
|
(value as any).properties,
|
|
(value as any).properties,
|
|
|
path,
|
|
path,
|
|
|
nodeName,
|
|
nodeName,
|
|
|
- nodeIdSource
|
|
|
|
|
|
|
+ nodeIdSource,
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1429,6 +1563,10 @@ function WorkflowEditorInner() {
|
|
|
|
|
|
|
|
for (const { node: sourceNode } of orderedNodes) {
|
|
for (const { node: sourceNode } of orderedNodes) {
|
|
|
const nodeName = sourceNode.data.label || sourceNode.data.type
|
|
const nodeName = sourceNode.data.label || sourceNode.data.type
|
|
|
|
|
+ // Check if this source node is a direct upstream (immediate predecessor) of the target node
|
|
|
|
|
+ const isDirectUpstream = directUpstreamIds.has(sourceNode.id)
|
|
|
|
|
+ // Get the targetInput for direct upstream nodes
|
|
|
|
|
+ const targetInput = isDirectUpstream ? directUpstreamTargetInputs.get(sourceNode.id) : undefined
|
|
|
|
|
|
|
|
const executionResult = lastExecutionResults[sourceNode.id]
|
|
const executionResult = lastExecutionResults[sourceNode.id]
|
|
|
const pinnedResult = nodeChangeStatus[sourceNode.id] === 'unchanged'
|
|
const pinnedResult = nodeChangeStatus[sourceNode.id] === 'unchanged'
|
|
@@ -1448,6 +1586,8 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: sourceNode.id,
|
|
nodeId: sourceNode.id,
|
|
|
displayName: 'currentIndex',
|
|
displayName: 'currentIndex',
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
path: 'currentItem',
|
|
path: 'currentItem',
|
|
@@ -1455,8 +1595,10 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: sourceNode.id,
|
|
nodeId: sourceNode.id,
|
|
|
displayName: 'currentItem',
|
|
displayName: 'currentItem',
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
children: itemData !== null && typeof itemData === 'object'
|
|
children: itemData !== null && typeof itemData === 'object'
|
|
|
- ? extractFieldsFromData(itemData, 'currentItem', nodeName, sourceNode.id)
|
|
|
|
|
|
|
+ ? extractFieldsFromData(itemData, 'currentItem', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput)
|
|
|
: undefined,
|
|
: undefined,
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
@@ -1465,15 +1607,17 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: sourceNode.id,
|
|
nodeId: sourceNode.id,
|
|
|
displayName: 'totalItems',
|
|
displayName: 'totalItems',
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
},
|
|
},
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
fields.push(...loopFields)
|
|
fields.push(...loopFields)
|
|
|
} else {
|
|
} else {
|
|
|
- fields.push(...extractFieldsFromData(dataSource, '', nodeName, sourceNode.id))
|
|
|
|
|
|
|
+ fields.push(...extractFieldsFromData(dataSource, '', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput))
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
- fields.push(...extractFieldsFromData(dataSource, '', nodeName, sourceNode.id))
|
|
|
|
|
|
|
+ fields.push(...extractFieldsFromData(dataSource, '', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput))
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
const nodeDef = nodeDefsMap[sourceNode.data.type]
|
|
const nodeDef = nodeDefsMap[sourceNode.data.type]
|
|
@@ -1512,6 +1656,8 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: sourceNode.id,
|
|
nodeId: sourceNode.id,
|
|
|
displayName: 'currentIndex',
|
|
displayName: 'currentIndex',
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
path: 'currentItem',
|
|
path: 'currentItem',
|
|
@@ -1519,8 +1665,10 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: sourceNode.id,
|
|
nodeId: sourceNode.id,
|
|
|
displayName: 'currentItem',
|
|
displayName: 'currentItem',
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
children: sampleItem !== null && typeof sampleItem === 'object'
|
|
children: sampleItem !== null && typeof sampleItem === 'object'
|
|
|
- ? extractFieldsFromData(sampleItem, 'currentItem', nodeName, sourceNode.id)
|
|
|
|
|
|
|
+ ? extractFieldsFromData(sampleItem, 'currentItem', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput)
|
|
|
: undefined,
|
|
: undefined,
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
@@ -1529,6 +1677,8 @@ function WorkflowEditorInner() {
|
|
|
nodeName,
|
|
nodeName,
|
|
|
nodeId: sourceNode.id,
|
|
nodeId: sourceNode.id,
|
|
|
displayName: 'totalItems',
|
|
displayName: 'totalItems',
|
|
|
|
|
+ isDirectUpstream,
|
|
|
|
|
+ targetInput,
|
|
|
},
|
|
},
|
|
|
]
|
|
]
|
|
|
|
|
|
|
@@ -1536,7 +1686,7 @@ function WorkflowEditorInner() {
|
|
|
} else {
|
|
} else {
|
|
|
const outputSchema = nodeDef.outputSchema as Record<string, any>
|
|
const outputSchema = nodeDef.outputSchema as Record<string, any>
|
|
|
if (outputSchema?.properties) {
|
|
if (outputSchema?.properties) {
|
|
|
- fields.push(...extractFieldsFromSchema(outputSchema.properties, '', nodeName, sourceNode.id))
|
|
|
|
|
|
|
+ fields.push(...extractFieldsFromSchema(outputSchema.properties, '', nodeName, sourceNode.id, isDirectUpstream, targetInput))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -1554,6 +1704,7 @@ function WorkflowEditorInner() {
|
|
|
type: f.type,
|
|
type: f.type,
|
|
|
nodeName: f.nodeName,
|
|
nodeName: f.nodeName,
|
|
|
displayName: f.displayName,
|
|
displayName: f.displayName,
|
|
|
|
|
+ isDirectUpstream: f.isDirectUpstream,
|
|
|
}))
|
|
}))
|
|
|
}, [getUpstreamOutputFields])
|
|
}, [getUpstreamOutputFields])
|
|
|
|
|
|
|
@@ -1607,7 +1758,7 @@ function WorkflowEditorInner() {
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
{/* Main content */}
|
|
{/* Main content */}
|
|
|
- <div className="flex-1 flex overflow-hidden">
|
|
|
|
|
|
|
+ <div className="flex-1 flex overflow-hidden min-h-0">
|
|
|
{/* Node picker sidebar (left) */}
|
|
{/* Node picker sidebar (left) */}
|
|
|
{showNodePicker && (
|
|
{showNodePicker && (
|
|
|
<NodePickerSidebar
|
|
<NodePickerSidebar
|
|
@@ -1620,26 +1771,30 @@ function WorkflowEditorInner() {
|
|
|
{/* Flow canvas */}
|
|
{/* Flow canvas */}
|
|
|
<div className={`flex-1 overflow-hidden ${(showExecutionPanel || isViewingExecution || pinnedExecution) ? 'w-2/3' : 'w-full'}`}>
|
|
<div className={`flex-1 overflow-hidden ${(showExecutionPanel || isViewingExecution || pinnedExecution) ? 'w-2/3' : 'w-full'}`}>
|
|
|
<ReactFlow
|
|
<ReactFlow
|
|
|
- nodes={nodes}
|
|
|
|
|
- edges={edges}
|
|
|
|
|
|
|
+ nodes={isViewingExecution && viewedNodes ? viewedNodes : nodes}
|
|
|
|
|
+ edges={isViewingExecution && viewedEdges ? viewedEdges : edges}
|
|
|
nodeTypes={nodeTypes}
|
|
nodeTypes={nodeTypes}
|
|
|
defaultEdgeOptions={{
|
|
defaultEdgeOptions={{
|
|
|
type: 'smoothstep',
|
|
type: 'smoothstep',
|
|
|
style: { strokeWidth: 2 },
|
|
style: { strokeWidth: 2 },
|
|
|
}}
|
|
}}
|
|
|
connectionLineType={ConnectionLineType.SmoothStep}
|
|
connectionLineType={ConnectionLineType.SmoothStep}
|
|
|
- snapToGrid={true}
|
|
|
|
|
|
|
+ snapToGrid={!isViewingExecution}
|
|
|
snapGrid={[20, 20]}
|
|
snapGrid={[20, 20]}
|
|
|
// Multi-selection: Shift+click to add to selection, Ctrl/Cmd+drag to pan
|
|
// Multi-selection: Shift+click to add to selection, Ctrl/Cmd+drag to pan
|
|
|
- selectionOnDrag={true}
|
|
|
|
|
|
|
+ selectionOnDrag={!isViewingExecution}
|
|
|
selectionMode={SelectionMode.Partial}
|
|
selectionMode={SelectionMode.Partial}
|
|
|
panOnDrag={[1, 2]} // Middle mouse button or right mouse for panning
|
|
panOnDrag={[1, 2]} // Middle mouse button or right mouse for panning
|
|
|
panOnScroll={true}
|
|
panOnScroll={true}
|
|
|
panOnScrollMode={PanOnScrollMode.Free}
|
|
panOnScrollMode={PanOnScrollMode.Free}
|
|
|
- selectionKeyCode="Shift"
|
|
|
|
|
- multiSelectionKeyCode="Shift"
|
|
|
|
|
|
|
+ selectionKeyCode={isViewingExecution ? null : "Shift"}
|
|
|
|
|
+ multiSelectionKeyCode={isViewingExecution ? null : "Shift"}
|
|
|
panActivationKeyCode="Control"
|
|
panActivationKeyCode="Control"
|
|
|
- onNodesChange={(changes) => {
|
|
|
|
|
|
|
+ // Disable editing when viewing execution snapshot
|
|
|
|
|
+ nodesDraggable={!isViewingExecution}
|
|
|
|
|
+ nodesConnectable={!isViewingExecution}
|
|
|
|
|
+ elementsSelectable={!isViewingExecution}
|
|
|
|
|
+ onNodesChange={isViewingExecution ? undefined : (changes) => {
|
|
|
// Only mark as changed for actual modifications, not selection
|
|
// Only mark as changed for actual modifications, not selection
|
|
|
const hasActualChange = changes.some((c) => {
|
|
const hasActualChange = changes.some((c) => {
|
|
|
if (c.type === 'remove') return true
|
|
if (c.type === 'remove') return true
|
|
@@ -1652,23 +1807,23 @@ function WorkflowEditorInner() {
|
|
|
}
|
|
}
|
|
|
onNodesChange(changes)
|
|
onNodesChange(changes)
|
|
|
}}
|
|
}}
|
|
|
- onEdgesChange={(changes) => {
|
|
|
|
|
|
|
+ onEdgesChange={isViewingExecution ? undefined : (changes) => {
|
|
|
// Only mark as changed for actual edge modifications
|
|
// Only mark as changed for actual edge modifications
|
|
|
if (changes.some((c) => c.type === 'remove')) {
|
|
if (changes.some((c) => c.type === 'remove')) {
|
|
|
setHasChanges(true)
|
|
setHasChanges(true)
|
|
|
}
|
|
}
|
|
|
onEdgesChange(changes)
|
|
onEdgesChange(changes)
|
|
|
}}
|
|
}}
|
|
|
- onConnect={onConnect}
|
|
|
|
|
|
|
+ onConnect={isViewingExecution ? undefined : onConnect}
|
|
|
onNodeClick={onNodeClick}
|
|
onNodeClick={onNodeClick}
|
|
|
- onNodeContextMenu={onNodeContextMenu}
|
|
|
|
|
- onNodeDoubleClick={onNodeDoubleClick}
|
|
|
|
|
|
|
+ onNodeContextMenu={isViewingExecution ? undefined : onNodeContextMenu}
|
|
|
|
|
+ onNodeDoubleClick={isViewingExecution ? undefined : onNodeDoubleClick}
|
|
|
onEdgeClick={onEdgeClick}
|
|
onEdgeClick={onEdgeClick}
|
|
|
- onEdgeContextMenu={onEdgeContextMenu}
|
|
|
|
|
|
|
+ onEdgeContextMenu={isViewingExecution ? undefined : onEdgeContextMenu}
|
|
|
onPaneClick={onPaneClick}
|
|
onPaneClick={onPaneClick}
|
|
|
fitView
|
|
fitView
|
|
|
deleteKeyCode={null}
|
|
deleteKeyCode={null}
|
|
|
- edgesUpdatable
|
|
|
|
|
|
|
+ edgesUpdatable={!isViewingExecution}
|
|
|
>
|
|
>
|
|
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
|
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
|
|
|
<Controls />
|
|
<Controls />
|
|
@@ -1705,8 +1860,8 @@ function WorkflowEditorInner() {
|
|
|
{(showExecutionPanel || isViewingExecution || pinnedExecution) && (
|
|
{(showExecutionPanel || isViewingExecution || pinnedExecution) && (
|
|
|
<ExecutionResultsPanel
|
|
<ExecutionResultsPanel
|
|
|
executionState={effectiveExecutionState}
|
|
executionState={effectiveExecutionState}
|
|
|
- nodes={nodes}
|
|
|
|
|
- edges={edges}
|
|
|
|
|
|
|
+ nodes={isViewingExecution && viewedNodes ? viewedNodes : nodes}
|
|
|
|
|
+ edges={isViewingExecution && viewedEdges ? viewedEdges : edges}
|
|
|
expandedNodes={expandedNodes}
|
|
expandedNodes={expandedNodes}
|
|
|
selectedIterations={selectedIterations}
|
|
selectedIterations={selectedIterations}
|
|
|
onClose={() => {
|
|
onClose={() => {
|