mirror of
https://github.com/Nikhil-Doye/workflow-builder.git
synced 2026-07-22 02:01:56 +02:00
Add execution logging and event handling components
- Introduced ExecutionLogger component to display real-time execution logs, including workflow and node events. - Added NodeExecutionIndicator component to visualize the status and progress of individual nodes during execution. - Enhanced WorkflowEditor to integrate execution logging, allowing users to toggle the logger and view execution metrics. - Implemented execution event bus to manage and emit events related to workflow execution, including start, progress, completion, and errors. - Updated ExecutionEngine to emit relevant events during execution phases for improved tracking and user feedback.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
executionEventBus,
|
||||
ExecutionEvent,
|
||||
NodeCompleteEvent,
|
||||
NodeStartEvent,
|
||||
} from "../services/executionEventBus";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
X,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Zap,
|
||||
Download,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: Date;
|
||||
level: "info" | "success" | "warning" | "error";
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
nodeLabel?: string;
|
||||
event?: ExecutionEvent;
|
||||
}
|
||||
|
||||
interface ExecutionLoggerProps {
|
||||
executionId?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ExecutionLogger: React.FC<ExecutionLoggerProps> = ({
|
||||
executionId,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEvent = (event: ExecutionEvent) => {
|
||||
const newLog: LogEntry = {
|
||||
timestamp: event.timestamp,
|
||||
event,
|
||||
level: "info",
|
||||
message: "",
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case "execution:start":
|
||||
newLog.message = `Workflow execution started (${event.executionMode} mode, ${event.nodeCount} nodes)`;
|
||||
newLog.level = "info";
|
||||
break;
|
||||
case "node:start":
|
||||
newLog.message = `Node started`;
|
||||
newLog.nodeId = event.nodeId;
|
||||
newLog.nodeLabel = event.nodeLabel;
|
||||
newLog.level = "info";
|
||||
break;
|
||||
case "node:progress":
|
||||
newLog.message = `${event.message} (${event.progress}%)`;
|
||||
newLog.nodeId = event.nodeId;
|
||||
newLog.level = "info";
|
||||
break;
|
||||
case "node:complete":
|
||||
const completeEvent = event as NodeCompleteEvent;
|
||||
if (completeEvent.status === "success") {
|
||||
newLog.message = `Node completed successfully (${completeEvent.duration}ms)`;
|
||||
newLog.level = "success";
|
||||
} else if (completeEvent.status === "failed") {
|
||||
newLog.message = `Node failed: ${completeEvent.error}`;
|
||||
newLog.level = "error";
|
||||
} else {
|
||||
newLog.message = `Node ${completeEvent.status}`;
|
||||
newLog.level = "warning";
|
||||
}
|
||||
newLog.nodeId = event.nodeId;
|
||||
break;
|
||||
case "execution:complete":
|
||||
newLog.message = `Workflow execution ${event.status} (${event.totalDuration}ms, ${event.completedNodes} completed, ${event.failedNodes} failed)`;
|
||||
newLog.level = event.status === "success" ? "success" : "error";
|
||||
break;
|
||||
case "execution:error":
|
||||
newLog.message = `Error during ${event.stage}: ${event.error}`;
|
||||
newLog.nodeId = event.nodeId;
|
||||
newLog.level = "error";
|
||||
break;
|
||||
}
|
||||
|
||||
setLogs((prev) => [...prev, newLog]);
|
||||
};
|
||||
|
||||
if (executionId) {
|
||||
unsubscribeRef.current = executionEventBus.subscribeToExecution(
|
||||
executionId,
|
||||
handleEvent
|
||||
);
|
||||
} else {
|
||||
// Subscribe to all events if no specific execution ID
|
||||
unsubscribeRef.current = () => {};
|
||||
const unsubscribers = [
|
||||
executionEventBus.subscribe("execution:start", handleEvent),
|
||||
executionEventBus.subscribe("node:start", handleEvent),
|
||||
executionEventBus.subscribe("node:progress", handleEvent),
|
||||
executionEventBus.subscribe("node:complete", handleEvent),
|
||||
executionEventBus.subscribe("execution:complete", handleEvent),
|
||||
executionEventBus.subscribe("execution:error", handleEvent),
|
||||
];
|
||||
unsubscribeRef.current = () => {
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
}
|
||||
};
|
||||
}, [isOpen, executionId]);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
if (autoScroll && logsContainerRef.current) {
|
||||
logsContainerRef.current.scrollTop =
|
||||
logsContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const downloadLogs = () => {
|
||||
const logsText = logs
|
||||
.map(
|
||||
(log) =>
|
||||
`[${log.timestamp.toISOString()}] [${log.level.toUpperCase()}] ${
|
||||
log.nodeLabel ? `(${log.nodeLabel}) ` : ""
|
||||
}${log.message}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const blob = new Blob([logsText], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `execution-logs-${Date.now()}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const clearLogs = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const getLogIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case "success":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />;
|
||||
case "error":
|
||||
return <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />;
|
||||
case "warning":
|
||||
return <Clock className="w-4 h-4 text-yellow-500 flex-shrink-0" />;
|
||||
default:
|
||||
return <Zap className="w-4 h-4 text-blue-500 flex-shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLogBgColor = (level: string) => {
|
||||
switch (level) {
|
||||
case "success":
|
||||
return "bg-green-50 border-l-4 border-green-500";
|
||||
case "error":
|
||||
return "bg-red-50 border-l-4 border-red-500";
|
||||
case "warning":
|
||||
return "bg-yellow-50 border-l-4 border-yellow-500";
|
||||
default:
|
||||
return "bg-blue-50 border-l-4 border-blue-500";
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 right-0 w-full lg:w-96 bg-white border-l border-t border-gray-200 shadow-2xl z-40 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-900">Execution Logs</h3>
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full font-medium">
|
||||
{logs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
autoScroll
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title={autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadLogs}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Download logs"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={clearLogs}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Clear logs"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title={isCollapsed ? "Expand" : "Collapse"}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Container */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="flex-1 overflow-y-auto bg-gray-50 font-mono text-xs"
|
||||
style={{ minHeight: "200px", maxHeight: "400px" }}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="p-4 text-gray-500 text-center">
|
||||
<p>No logs yet. Execute a workflow to see logs here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-2 rounded transition-colors ${getLogBgColor(
|
||||
log.level
|
||||
)}`}
|
||||
>
|
||||
<div className="flex items-start space-x-2">
|
||||
{getLogIcon(log.level)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
||||
<span className="text-gray-500">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
{log.nodeLabel && (
|
||||
<span className="bg-white/60 px-2 py-0.5 rounded text-gray-700 font-semibold">
|
||||
{log.nodeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-700 mt-0.5 break-words">
|
||||
{log.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ExecutionData {
|
||||
status: "pending" | "running" | "completed" | "failed" | "skipped";
|
||||
progress?: number;
|
||||
duration?: number;
|
||||
output?: any;
|
||||
error?: string;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
interface NodeExecutionIndicatorProps {
|
||||
nodeId: string;
|
||||
executionData?: ExecutionData;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
}
|
||||
|
||||
export const NodeExecutionIndicator: React.FC<NodeExecutionIndicatorProps> = ({
|
||||
nodeId,
|
||||
executionData,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}) => {
|
||||
if (!executionData) return null;
|
||||
|
||||
const { status, progress, duration, output, error, retryCount } =
|
||||
executionData;
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "border-blue-500 bg-blue-50";
|
||||
case "completed":
|
||||
return "border-green-500 bg-green-50";
|
||||
case "failed":
|
||||
return "border-red-500 bg-red-50";
|
||||
case "skipped":
|
||||
return "border-yellow-500 bg-yellow-50";
|
||||
default:
|
||||
return "border-gray-300 bg-gray-50";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Zap className="w-4 h-4 text-blue-500 animate-spin" />;
|
||||
case "completed":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
case "skipped":
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-0 rounded-2xl border-2 pointer-events-auto transition-all ${getStatusColor()}`}
|
||||
style={{ zIndex: 1000 }}
|
||||
>
|
||||
{/* Status Overlay Badge */}
|
||||
<div className="absolute -top-3 -right-3 flex items-center space-x-1 px-2 py-1 bg-white rounded-full shadow-lg border border-gray-200">
|
||||
{getStatusIcon()}
|
||||
<span className="text-xs font-semibold capitalize text-gray-900">
|
||||
{status}
|
||||
</span>
|
||||
{retryCount ? (
|
||||
<span className="ml-1 text-xs bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded-full font-medium">
|
||||
Retry {retryCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{status === "running" && progress !== undefined && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-200 rounded-b-xl overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-400 to-blue-600 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Time */}
|
||||
{duration && status === "completed" && (
|
||||
<div className="absolute -bottom-6 left-0 text-xs text-gray-500 font-medium">
|
||||
{duration}ms
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Badge */}
|
||||
{status === "failed" && error && (
|
||||
<div className="absolute top-12 left-0 right-0 mx-2 p-2 bg-red-100 border border-red-300 rounded text-xs text-red-700 font-medium truncate">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Preview */}
|
||||
{status === "completed" && output && isExpanded && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-white border border-green-300 rounded-lg shadow-lg z-50 max-w-xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-700">Output</span>
|
||||
<button
|
||||
onClick={onToggleExpand}
|
||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
<ChevronUp className="w-3 h-3 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-600 whitespace-pre-wrap overflow-auto max-h-48">
|
||||
{typeof output === "string"
|
||||
? output
|
||||
: JSON.stringify(output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Toggle Button */}
|
||||
{status === "completed" && output && !isExpanded && (
|
||||
<button
|
||||
onClick={onToggleExpand}
|
||||
className="absolute -bottom-6 right-0 flex items-center space-x-1 text-xs text-green-600 font-medium hover:text-green-700 transition-colors"
|
||||
>
|
||||
<span>View output</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, useRef, useState, useMemo } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
@@ -22,6 +28,7 @@ import { NodeLibrary } from "./NodeLibrary";
|
||||
import { ConnectionSuggestions } from "./ConnectionSuggestions";
|
||||
import { ConnectionValidation } from "./ConnectionValidation";
|
||||
import { InteractiveTutorial } from "./InteractiveTutorial";
|
||||
import { ExecutionLogger } from "./ExecutionLogger";
|
||||
import {
|
||||
WebScrapingNode,
|
||||
LLMNode,
|
||||
@@ -40,7 +47,19 @@ import {
|
||||
GmailNode,
|
||||
} from "./nodes";
|
||||
import { NodeData } from "../types";
|
||||
import { Grid, Trash2, Sparkles, X, Lightbulb, Play } from "lucide-react";
|
||||
import {
|
||||
Grid,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
X,
|
||||
Lightbulb,
|
||||
Play,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
executionEventBus,
|
||||
ExecutionEvent,
|
||||
} from "../services/executionEventBus";
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
webScraping: WebScrapingNode,
|
||||
@@ -76,7 +95,10 @@ export const WorkflowEditor: React.FC<WorkflowEditorProps> = ({ onClose }) => {
|
||||
panelStates,
|
||||
togglePanel,
|
||||
updateNodePosition,
|
||||
isExecuting,
|
||||
currentExecution,
|
||||
} = useWorkflowStore();
|
||||
|
||||
const nodes = useMemo(
|
||||
() => currentWorkflow?.nodes || [],
|
||||
[currentWorkflow?.nodes]
|
||||
@@ -86,13 +108,61 @@ export const WorkflowEditor: React.FC<WorkflowEditorProps> = ({ onClose }) => {
|
||||
const [showCopilot, setShowCopilot] = useState(false);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
const [showExecutionLogger, setShowExecutionLogger] = useState(false);
|
||||
const [connectionSource, setConnectionSource] =
|
||||
useState<Node<NodeData> | null>(null);
|
||||
const [connectionTarget, setConnectionTarget] =
|
||||
useState<Node<NodeData> | null>(null);
|
||||
const [activeExecutionId, setActiveExecutionId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [executionMetrics, setExecutionMetrics] = useState<{
|
||||
completedNodes: number;
|
||||
failedNodes: number;
|
||||
totalDuration: number;
|
||||
}>({ completedNodes: 0, failedNodes: 0, totalDuration: 0 });
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const [reactFlowInstance, setReactFlowInstance] =
|
||||
useState<ReactFlowInstance | null>(null);
|
||||
const executionUnsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Subscribe to execution events for real-time feedback
|
||||
useEffect(() => {
|
||||
if (isExecuting && currentExecution?.id) {
|
||||
setActiveExecutionId(currentExecution.id);
|
||||
setShowExecutionLogger(true);
|
||||
|
||||
// Subscribe to execution events
|
||||
executionUnsubscribeRef.current = executionEventBus.subscribeToExecution(
|
||||
currentExecution.id,
|
||||
(event: ExecutionEvent) => {
|
||||
// Update execution metrics
|
||||
if (event.type === "execution:complete") {
|
||||
setExecutionMetrics({
|
||||
completedNodes: event.completedNodes,
|
||||
failedNodes: event.failedNodes,
|
||||
totalDuration: event.totalDuration,
|
||||
});
|
||||
} else if (event.type === "node:complete") {
|
||||
setExecutionMetrics((prev) => ({
|
||||
...prev,
|
||||
completedNodes:
|
||||
prev.completedNodes + (event.status === "success" ? 1 : 0),
|
||||
failedNodes:
|
||||
prev.failedNodes + (event.status === "failed" ? 1 : 0),
|
||||
}));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (executionUnsubscribeRef.current) {
|
||||
executionUnsubscribeRef.current();
|
||||
executionUnsubscribeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isExecuting, currentExecution?.id]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
@@ -314,6 +384,25 @@ export const WorkflowEditor: React.FC<WorkflowEditorProps> = ({ onClose }) => {
|
||||
<span className="font-medium">Tutorial</span>
|
||||
</button>
|
||||
|
||||
{/* Execution Logger Toggle */}
|
||||
<button
|
||||
onClick={() => setShowExecutionLogger(!showExecutionLogger)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
showExecutionLogger || isExecuting
|
||||
? "bg-blue-100 text-blue-700 border border-blue-200"
|
||||
: "bg-gray-50 text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
title="Toggle execution logger"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
<span className="font-medium">
|
||||
{isExecuting ? "Executing..." : "Logs"}
|
||||
</span>
|
||||
{isExecuting && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{selectedNodeId && (
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
@@ -327,6 +416,21 @@ export const WorkflowEditor: React.FC<WorkflowEditorProps> = ({ onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Execution Metrics */}
|
||||
{isExecuting && (
|
||||
<div className="flex items-center space-x-2 px-3 py-1 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<Activity className="w-4 h-4 text-blue-600 animate-spin" />
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
{executionMetrics.completedNodes}/{nodes.length} completed
|
||||
</span>
|
||||
{executionMetrics.failedNodes > 0 && (
|
||||
<span className="text-sm font-medium text-red-700">
|
||||
({executionMetrics.failedNodes} failed)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span>{nodes.length} nodes</span>
|
||||
@@ -370,7 +474,9 @@ export const WorkflowEditor: React.FC<WorkflowEditorProps> = ({ onClose }) => {
|
||||
maxZoom: 2,
|
||||
}}
|
||||
attributionPosition="bottom-left"
|
||||
className="bg-gradient-to-br from-gray-50 to-blue-50/30"
|
||||
className={`bg-gradient-to-br from-gray-50 to-blue-50/30 ${
|
||||
isExecuting ? "opacity-75" : ""
|
||||
}`}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
>
|
||||
@@ -474,6 +580,13 @@ export const WorkflowEditor: React.FC<WorkflowEditorProps> = ({ onClose }) => {
|
||||
workflowNodes={nodes}
|
||||
workflowEdges={edges}
|
||||
/>
|
||||
|
||||
{/* Execution Logger */}
|
||||
<ExecutionLogger
|
||||
executionId={activeExecutionId || undefined}
|
||||
isOpen={showExecutionLogger}
|
||||
onClose={() => setShowExecutionLogger(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+137
-22
@@ -1,5 +1,14 @@
|
||||
// Import variable substitution utilities
|
||||
import { substituteVariables, NodeOutput } from "../utils/variableSubstitution";
|
||||
import {
|
||||
executionEventBus,
|
||||
ExecutionStartEvent,
|
||||
NodeStartEvent,
|
||||
NodeProgressEvent,
|
||||
NodeCompleteEvent,
|
||||
ExecutionCompleteEvent,
|
||||
ExecutionErrorEvent,
|
||||
} from "./executionEventBus";
|
||||
|
||||
export interface ExecutionContext {
|
||||
nodeId: string;
|
||||
@@ -88,6 +97,20 @@ export class ExecutionEngine {
|
||||
options,
|
||||
});
|
||||
|
||||
// Emit execution start event
|
||||
const executionStartEvent: ExecutionStartEvent = {
|
||||
type: "execution:start",
|
||||
executionId,
|
||||
workflowId,
|
||||
timestamp: new Date(),
|
||||
nodeCount: nodes.length,
|
||||
executionMode: (options.mode || "sequential") as
|
||||
| "sequential"
|
||||
| "parallel"
|
||||
| "conditional",
|
||||
};
|
||||
executionEventBus.emit(executionStartEvent);
|
||||
|
||||
// Validate workflow before execution
|
||||
try {
|
||||
this.validateWorkflowStructure(nodes, edges);
|
||||
@@ -96,6 +119,16 @@ export class ExecutionEngine {
|
||||
error instanceof Error ? error.message : "Unknown validation error";
|
||||
console.error("Workflow validation failed:", errorMessage);
|
||||
|
||||
// Emit validation error event
|
||||
const validationErrorEvent: ExecutionErrorEvent = {
|
||||
type: "execution:error",
|
||||
executionId,
|
||||
stage: "validation",
|
||||
error: errorMessage,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
executionEventBus.emit(validationErrorEvent);
|
||||
|
||||
// Create a failed execution plan
|
||||
const failedPlan: ExecutionPlan = {
|
||||
id: executionId,
|
||||
@@ -131,14 +164,55 @@ export class ExecutionEngine {
|
||||
this.activeExecutions.set(executionId, plan);
|
||||
|
||||
try {
|
||||
await this.executePlan(plan, onNodeUpdate);
|
||||
await this.executePlan(plan, onNodeUpdate, executionId);
|
||||
plan.status = "completed";
|
||||
|
||||
// Emit execution complete event
|
||||
const executionCompleteEvent: ExecutionCompleteEvent = {
|
||||
type: "execution:complete",
|
||||
executionId,
|
||||
workflowId,
|
||||
status: "success",
|
||||
totalDuration: plan.totalDuration || 0,
|
||||
completedNodes: plan.nodes.filter((n) => n.status === "completed")
|
||||
.length,
|
||||
failedNodes: plan.nodes.filter((n) => n.status === "failed").length,
|
||||
results: plan.results,
|
||||
errors: plan.errors,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
executionEventBus.emit(executionCompleteEvent);
|
||||
} catch (error) {
|
||||
plan.status = "failed";
|
||||
plan.errors.set(
|
||||
"root",
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
plan.errors.set("root", errorMessage);
|
||||
|
||||
// Emit execution error event
|
||||
const executionErrorEvent: ExecutionErrorEvent = {
|
||||
type: "execution:error",
|
||||
executionId,
|
||||
stage: "execution",
|
||||
error: errorMessage,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
executionEventBus.emit(executionErrorEvent);
|
||||
|
||||
// Emit execution complete event with failure status
|
||||
const executionCompleteEvent: ExecutionCompleteEvent = {
|
||||
type: "execution:complete",
|
||||
executionId,
|
||||
workflowId,
|
||||
status: "failed",
|
||||
totalDuration: plan.totalDuration || 0,
|
||||
completedNodes: plan.nodes.filter((n) => n.status === "completed")
|
||||
.length,
|
||||
failedNodes: plan.nodes.filter((n) => n.status === "failed").length,
|
||||
results: plan.results,
|
||||
errors: plan.errors,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
executionEventBus.emit(executionCompleteEvent);
|
||||
} finally {
|
||||
plan.endTime = new Date();
|
||||
plan.totalDuration =
|
||||
@@ -367,20 +441,21 @@ export class ExecutionEngine {
|
||||
status: string,
|
||||
data?: any,
|
||||
error?: string
|
||||
) => void
|
||||
) => void,
|
||||
executionId: string
|
||||
): Promise<void> {
|
||||
plan.status = "running";
|
||||
plan.startTime = new Date();
|
||||
|
||||
switch (plan.executionMode) {
|
||||
case "sequential":
|
||||
await this.executeSequential(plan, onNodeUpdate);
|
||||
await this.executeSequential(plan, onNodeUpdate, executionId);
|
||||
break;
|
||||
case "parallel":
|
||||
await this.executeParallel(plan, onNodeUpdate);
|
||||
await this.executeParallel(plan, onNodeUpdate, executionId);
|
||||
break;
|
||||
case "conditional":
|
||||
await this.executeConditional(plan, onNodeUpdate);
|
||||
await this.executeConditional(plan, onNodeUpdate, executionId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported execution mode: ${plan.executionMode}`);
|
||||
@@ -395,7 +470,8 @@ export class ExecutionEngine {
|
||||
status: string,
|
||||
data?: any,
|
||||
error?: string
|
||||
) => void
|
||||
) => void,
|
||||
executionId: string
|
||||
): Promise<void> {
|
||||
const executionOrder = this.getExecutionOrder(plan.nodes, plan.edges);
|
||||
|
||||
@@ -418,7 +494,7 @@ export class ExecutionEngine {
|
||||
if (!context) continue;
|
||||
|
||||
try {
|
||||
await this.executeNode(context, plan, onNodeUpdate);
|
||||
await this.executeNode(context, plan, onNodeUpdate, executionId);
|
||||
} catch (error) {
|
||||
context.status = "failed";
|
||||
context.error =
|
||||
@@ -446,7 +522,8 @@ export class ExecutionEngine {
|
||||
status: string,
|
||||
data?: any,
|
||||
error?: string
|
||||
) => void
|
||||
) => void,
|
||||
executionId: string
|
||||
): Promise<void> {
|
||||
const parallelGroups = this.createParallelGroups(
|
||||
plan.nodes,
|
||||
@@ -456,7 +533,7 @@ export class ExecutionEngine {
|
||||
|
||||
// Execute groups in parallel
|
||||
const groupPromises = parallelGroups.map((group) =>
|
||||
this.executeParallelGroup(group, plan, onNodeUpdate)
|
||||
this.executeParallelGroup(group, plan, onNodeUpdate, executionId)
|
||||
);
|
||||
|
||||
await Promise.allSettled(groupPromises);
|
||||
@@ -471,13 +548,14 @@ export class ExecutionEngine {
|
||||
status: string,
|
||||
data?: any,
|
||||
error?: string
|
||||
) => void
|
||||
) => void,
|
||||
executionId: string
|
||||
): Promise<void> {
|
||||
const nodePromises = group.nodes.map((nodeId) => {
|
||||
const context = plan.nodes.find((n) => n.nodeId === nodeId);
|
||||
if (!context) return Promise.resolve();
|
||||
|
||||
return this.executeNode(context, plan, onNodeUpdate);
|
||||
return this.executeNode(context, plan, onNodeUpdate, executionId);
|
||||
});
|
||||
|
||||
if (group.waitForAll) {
|
||||
@@ -495,7 +573,8 @@ export class ExecutionEngine {
|
||||
status: string,
|
||||
data?: any,
|
||||
error?: string
|
||||
) => void
|
||||
) => void,
|
||||
executionId: string
|
||||
): Promise<void> {
|
||||
const executionOrder = this.getExecutionOrder(plan.nodes, plan.edges);
|
||||
const visited = new Set<string>();
|
||||
@@ -507,7 +586,7 @@ export class ExecutionEngine {
|
||||
if (!context) continue;
|
||||
|
||||
try {
|
||||
await this.executeNode(context, plan, onNodeUpdate);
|
||||
await this.executeNode(context, plan, onNodeUpdate, executionId);
|
||||
visited.add(nodeId);
|
||||
|
||||
// Check for conditional branches
|
||||
@@ -517,7 +596,8 @@ export class ExecutionEngine {
|
||||
branches,
|
||||
plan,
|
||||
visited,
|
||||
onNodeUpdate
|
||||
onNodeUpdate,
|
||||
executionId
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -568,7 +648,8 @@ export class ExecutionEngine {
|
||||
status: string,
|
||||
data?: any,
|
||||
error?: string
|
||||
) => void
|
||||
) => void,
|
||||
executionId: string
|
||||
): Promise<void> {
|
||||
for (const branch of branches) {
|
||||
const shouldExecuteTrue = await this.evaluateCondition(
|
||||
@@ -587,7 +668,7 @@ export class ExecutionEngine {
|
||||
if (!context) continue;
|
||||
|
||||
try {
|
||||
await this.executeNode(context, plan, onNodeUpdate);
|
||||
await this.executeNode(context, plan, onNodeUpdate, executionId);
|
||||
visited.add(nodeId);
|
||||
} catch (error) {
|
||||
context.status = "failed";
|
||||
@@ -729,11 +810,22 @@ export class ExecutionEngine {
|
||||
status: string,
|
||||
data?: any,
|
||||
error?: string
|
||||
) => void
|
||||
) => void,
|
||||
executionId: string
|
||||
): Promise<void> {
|
||||
context.status = "running";
|
||||
context.startTime = new Date();
|
||||
|
||||
// Emit node start event
|
||||
const nodeStartEvent: NodeStartEvent = {
|
||||
type: "node:start",
|
||||
executionId,
|
||||
nodeId: context.nodeId,
|
||||
nodeType: context.nodeType,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
executionEventBus.emit(nodeStartEvent);
|
||||
|
||||
// Notify that node is starting
|
||||
if (onNodeUpdate) {
|
||||
onNodeUpdate(context.nodeId, "running");
|
||||
@@ -782,6 +874,18 @@ export class ExecutionEngine {
|
||||
// Store in plan results
|
||||
plan.results.set(context.nodeId, result);
|
||||
|
||||
// Emit node complete event
|
||||
const nodeCompleteEvent: NodeCompleteEvent = {
|
||||
type: "node:complete",
|
||||
executionId,
|
||||
nodeId: context.nodeId,
|
||||
nodeType: context.nodeType,
|
||||
result: result,
|
||||
duration: context.duration || 0,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
executionEventBus.emit(nodeCompleteEvent);
|
||||
|
||||
// Notify that node completed successfully
|
||||
if (onNodeUpdate) {
|
||||
onNodeUpdate(context.nodeId, "completed", result);
|
||||
@@ -793,6 +897,17 @@ export class ExecutionEngine {
|
||||
context.duration =
|
||||
context.endTime.getTime() - context.startTime.getTime();
|
||||
|
||||
// Emit node error event
|
||||
const nodeErrorEvent: ExecutionErrorEvent = {
|
||||
type: "node:error",
|
||||
executionId,
|
||||
nodeId: context.nodeId,
|
||||
nodeType: context.nodeType,
|
||||
error: context.error || "Unknown error",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
executionEventBus.emit(nodeErrorEvent);
|
||||
|
||||
// Notify about the error
|
||||
if (onNodeUpdate) {
|
||||
onNodeUpdate(context.nodeId, "failed", undefined, context.error);
|
||||
@@ -809,7 +924,7 @@ export class ExecutionEngine {
|
||||
);
|
||||
|
||||
// Retry execution
|
||||
return this.executeNode(context, plan, onNodeUpdate);
|
||||
return this.executeNode(context, plan, onNodeUpdate, executionId);
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Event types for execution lifecycle
|
||||
export interface ExecutionStartEvent {
|
||||
type: "execution:start";
|
||||
executionId: string;
|
||||
workflowId: string;
|
||||
timestamp: Date;
|
||||
nodeCount: number;
|
||||
executionMode: "sequential" | "parallel" | "conditional";
|
||||
}
|
||||
|
||||
export interface NodeStartEvent {
|
||||
type: "node:start";
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
nodeLabel?: string;
|
||||
timestamp: Date;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface NodeProgressEvent {
|
||||
type: "node:progress";
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
progress: number; // 0-100
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface NodeCompleteEvent {
|
||||
type: "node:complete";
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
nodeType?: string;
|
||||
status?: "success" | "failed" | "skipped";
|
||||
output?: any;
|
||||
result?: any;
|
||||
error?: string;
|
||||
duration: number; // milliseconds
|
||||
timestamp: Date;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
export interface ExecutionCompleteEvent {
|
||||
type: "execution:complete";
|
||||
executionId: string;
|
||||
workflowId: string;
|
||||
status: "success" | "failed" | "cancelled";
|
||||
totalDuration: number; // milliseconds
|
||||
completedNodes: number;
|
||||
failedNodes: number;
|
||||
results: Map<string, any>;
|
||||
errors: Map<string, string>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface ExecutionErrorEvent {
|
||||
type: "execution:error" | "node:error";
|
||||
executionId: string;
|
||||
stage?: "validation" | "planning" | "execution";
|
||||
error: string;
|
||||
nodeId?: string;
|
||||
nodeType?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export type ExecutionEvent =
|
||||
| ExecutionStartEvent
|
||||
| NodeStartEvent
|
||||
| NodeProgressEvent
|
||||
| NodeCompleteEvent
|
||||
| ExecutionCompleteEvent
|
||||
| ExecutionErrorEvent;
|
||||
|
||||
type EventListener<T extends ExecutionEvent = ExecutionEvent> = (
|
||||
event: T
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* ExecutionEventBus - Centralized event emitter for workflow execution events
|
||||
* Provides real-time feedback on execution progress to all subscribers
|
||||
*/
|
||||
export class ExecutionEventBus {
|
||||
private listeners: Map<ExecutionEvent["type"], Set<EventListener>> =
|
||||
new Map();
|
||||
private eventHistory: ExecutionEvent[] = [];
|
||||
private maxHistorySize = 1000;
|
||||
private activeExecutions: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* Subscribe to a specific execution event type
|
||||
*/
|
||||
subscribe<T extends ExecutionEvent>(
|
||||
eventType: T["type"],
|
||||
listener: EventListener<T>
|
||||
): () => void {
|
||||
if (!this.listeners.has(eventType)) {
|
||||
this.listeners.set(eventType, new Set());
|
||||
}
|
||||
|
||||
const listeners = this.listeners.get(eventType)!;
|
||||
listeners.add(listener as EventListener);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
listeners.delete(listener as EventListener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all events from a specific execution
|
||||
*/
|
||||
subscribeToExecution(
|
||||
executionId: string,
|
||||
listener: EventListener
|
||||
): () => void {
|
||||
const eventTypes: ExecutionEvent["type"][] = [
|
||||
"execution:start",
|
||||
"node:start",
|
||||
"node:progress",
|
||||
"node:complete",
|
||||
"execution:complete",
|
||||
"execution:error",
|
||||
];
|
||||
|
||||
const unsubscribers = eventTypes.map((eventType) =>
|
||||
this.subscribe(eventType, (event: any) => {
|
||||
if (event.executionId === executionId) {
|
||||
listener(event);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an execution event
|
||||
*/
|
||||
emit(event: ExecutionEvent): void {
|
||||
// Track active executions
|
||||
if (event.type === "execution:start") {
|
||||
this.activeExecutions.add(event.executionId);
|
||||
} else if (event.type === "execution:complete") {
|
||||
this.activeExecutions.delete(event.executionId);
|
||||
}
|
||||
|
||||
// Store in history
|
||||
this.eventHistory.push(event);
|
||||
if (this.eventHistory.length > this.maxHistorySize) {
|
||||
this.eventHistory.shift();
|
||||
}
|
||||
|
||||
// Emit to listeners
|
||||
const listeners = this.listeners.get(event.type);
|
||||
if (listeners) {
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error("Error in event listener:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Log important events
|
||||
if (
|
||||
event.type === "execution:start" ||
|
||||
event.type === "execution:complete" ||
|
||||
event.type === "execution:error" ||
|
||||
(event.type === "node:complete" && event.status !== "success")
|
||||
) {
|
||||
console.log(`[ExecutionEventBus] ${event.type}`, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event history filtered by type or execution
|
||||
*/
|
||||
getHistory(filter?: {
|
||||
executionId?: string;
|
||||
eventType?: ExecutionEvent["type"];
|
||||
limit?: number;
|
||||
}): ExecutionEvent[] {
|
||||
let history = [...this.eventHistory];
|
||||
|
||||
if (filter?.executionId) {
|
||||
history = history.filter((e) => e.executionId === filter.executionId);
|
||||
}
|
||||
|
||||
if (filter?.eventType) {
|
||||
history = history.filter((e) => e.type === filter.eventType);
|
||||
}
|
||||
|
||||
if (filter?.limit) {
|
||||
history = history.slice(-filter.limit);
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active execution IDs
|
||||
*/
|
||||
getActiveExecutions(): string[] {
|
||||
return Array.from(this.activeExecutions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if execution is active
|
||||
*/
|
||||
isExecutionActive(executionId: string): boolean {
|
||||
return this.activeExecutions.has(executionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear event history (useful for testing or cleanup)
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.eventHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all listeners
|
||||
*/
|
||||
clear(): void {
|
||||
this.listeners.clear();
|
||||
this.eventHistory = [];
|
||||
this.activeExecutions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const executionEventBus = new ExecutionEventBus();
|
||||
Reference in New Issue
Block a user