diff --git a/agentwatch/api/server.py b/agentwatch/api/server.py index cb729010..1af03ea3 100644 --- a/agentwatch/api/server.py +++ b/agentwatch/api/server.py @@ -1380,6 +1380,324 @@ async def seed_demo(_auth: None = Depends(_require_api_key)) -> dict[str, Any]: return {"status": "seeded"} +# ───────────────────────────────────────────── +# Visual Workflow Builder Endpoints (#447) +# ───────────────────────────────────────────── + +class WorkflowNode(BaseModel): + id: str + type: str + position: dict[str, float] + data: dict[str, Any] + +class WorkflowEdge(BaseModel): + id: str + source: str + target: str + source_handle: str | None = None + target_handle: str | None = None + +class Workflow(BaseModel): + id: str + name: str + description: str | None = None + nodes: list[WorkflowNode] + edges: list[WorkflowEdge] + created_at: str | None = None + updated_at: str | None = None + +DEFAULT_TEMPLATES = [ + { + "id": "tpl-code-review", + "name": "Multi-Agent Code Review Pipeline", + "description": "Orchestrates LLM nodes to review pull requests, check security policies, and post summaries.", + "nodes": [ + {"id": "node-1", "type": "scheduler", "position": {"x": 100, "y": 200}, "data": {"name": "PR Webhook Trigger", "cron": "*/5 * * * *", "triggerEvent": "pull_request"}}, + {"id": "node-2", "type": "custom-tool", "position": {"x": 300, "y": 200}, "data": {"name": "Fetch PR Diff", "toolName": "github_pr_fetcher", "arguments": "{\n \"pr_id\": \"{{trigger.pr_id}}\"\n}"}}, + {"id": "node-3", "type": "llm", "position": {"x": 550, "y": 100}, "data": {"name": "Security Analyzer", "model": "claude-3-5-sonnet", "temperature": 0.2, "systemPrompt": "Analyze the git diff for security vulnerabilities."}}, + {"id": "node-4", "type": "llm", "position": {"x": 550, "y": 300}, "data": {"name": "Style Guide Checker", "model": "gpt-4o", "temperature": 0.5, "systemPrompt": "Check the code for style violations."}}, + {"id": "node-5", "type": "conditional", "position": {"x": 800, "y": 200}, "data": {"name": "Vulnerabilities Found?", "property": "security_issues", "operator": "gt", "value": "0"}}, + {"id": "node-6", "type": "agent", "position": {"x": 1020, "y": 100}, "data": {"name": "Senior Reviewer Agent", "framework": "crewai", "agentName": "Reviewer", "systemPrompt": "Draft a detailed PR rejection comment detailing safety and style issues."}}, + {"id": "node-7", "type": "http", "position": {"x": 1020, "y": 300}, "data": {"name": "Post Approval Webhook", "url": "https://api.github.com/repos/owner/repo/pulls/comments", "method": "POST"}}, + ], + "edges": [ + {"id": "edge-1", "source": "node-1", "target": "node-2"}, + {"id": "edge-2", "source": "node-2", "target": "node-3"}, + {"id": "edge-3", "source": "node-2", "target": "node-4"}, + {"id": "edge-4", "source": "node-3", "target": "node-5"}, + {"id": "edge-5", "source": "node-4", "target": "node-5"}, + {"id": "edge-6", "source": "node-5", "target": "node-6", "source_handle": "true"}, + {"id": "edge-7", "source": "node-5", "target": "node-7", "source_handle": "false"}, + ] + }, + { + "id": "tpl-rag", + "name": "Retrieval-Augmented LLM Search", + "description": "Retrieves local memory context, processes it with LLM, and writes back findings to memory.", + "nodes": [ + {"id": "node-1", "type": "http", "position": {"x": 100, "y": 200}, "data": {"name": "Search Query Input", "url": "https://api.agentwatch.com/search-intent", "method": "GET"}}, + {"id": "node-2", "type": "memory", "position": {"x": 350, "y": 200}, "data": {"name": "Semantic Vector Search", "memoryKey": "knowledge_base", "mode": "read", "storageType": "semantic"}}, + {"id": "node-3", "type": "llm", "position": {"x": 600, "y": 200}, "data": {"name": "Synthesis Model", "model": "claude-3-5-sonnet", "temperature": 0.3, "systemPrompt": "Answer user query using the retrieved context."}}, + {"id": "node-4", "type": "memory", "position": {"x": 850, "y": 200}, "data": {"name": "Cache Result", "memoryKey": "search_history", "mode": "write", "storageType": "episodic"}}, + ], + "edges": [ + {"id": "edge-1", "source": "node-1", "target": "node-2"}, + {"id": "edge-2", "source": "node-2", "target": "node-3"}, + {"id": "edge-3", "source": "node-3", "target": "node-4"}, + ] + } +] + +import json +WORKFLOWS_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "workflows.json") + +def load_workflows_from_disk() -> list[dict]: + try: + if os.path.exists(WORKFLOWS_FILE): + with open(WORKFLOWS_FILE, "r") as f: + data = json.load(f) + if isinstance(data, list): + # Ensure defaults exist + existing_ids = {w["id"] for w in data} + for default in DEFAULT_TEMPLATES: + if default["id"] not in existing_ids: + data.append(default) + return data + except Exception as e: + logger.warning(f"Failed to load workflows: {e}") + return list(DEFAULT_TEMPLATES) + +def save_workflows_to_disk(workflows: list[dict]) -> None: + try: + with open(WORKFLOWS_FILE, "w") as f: + json.dump(workflows, f, indent=2) + except Exception as e: + logger.warning(f"Failed to save workflows: {e}") + +@app.get("/api/workflows") +@app.get("/api/v1/workflows") +async def list_workflows(request: Request, _auth: None = Depends(_require_api_key)) -> list[dict]: + _limiter.check(_rate_limit_key(request, "r"), RATE_READ, request) + return load_workflows_from_disk() + +@app.get("/api/workflows/{workflow_id}") +@app.get("/api/v1/workflows/{workflow_id}") +async def get_workflow(request: Request, workflow_id: str, _auth: None = Depends(_require_api_key)) -> dict: + _limiter.check(_rate_limit_key(request, "r"), RATE_READ, request) + workflows = load_workflows_from_disk() + for w in workflows: + if w["id"] == workflow_id: + return w + raise HTTPException(status_code=404, detail="Workflow not found") + +@app.post("/api/workflows") +@app.post("/api/v1/workflows") +async def save_workflow(request: Request, workflow: Workflow, _auth: None = Depends(_require_api_key)) -> dict: + _limiter.check(_rate_limit_key(request, "w"), RATE_WRITE, request) + workflows = load_workflows_from_disk() + workflow_dict = workflow.model_dump(mode="json") + workflow_dict["updated_at"] = datetime.now(UTC).isoformat() + + updated = False + for i, w in enumerate(workflows): + if w["id"] == workflow.id: + workflow_dict["created_at"] = w.get("created_at") or workflow_dict["updated_at"] + workflows[i] = workflow_dict + updated = True + break + + if not updated: + workflow_dict["created_at"] = workflow_dict["updated_at"] + workflows.append(workflow_dict) + + save_workflows_to_disk(workflows) + return {"status": "saved", "workflow": workflow_dict} + +@app.delete("/api/workflows/{workflow_id}") +@app.delete("/api/v1/workflows/{workflow_id}") +async def delete_workflow(request: Request, workflow_id: str, _auth: None = Depends(_require_api_key)) -> dict: + _limiter.check(_rate_limit_key(request, "w"), RATE_WRITE, request) + workflows = load_workflows_from_disk() + new_workflows = [w for w in workflows if w["id"] != workflow_id] + if len(new_workflows) == len(workflows): + raise HTTPException(status_code=404, detail="Workflow not found") + save_workflows_to_disk(new_workflows) + return {"status": "deleted"} + +@app.post("/api/workflows/{workflow_id}/run") +@app.post("/api/v1/workflows/{workflow_id}/run") +async def run_workflow_simulation(request: Request, workflow_id: str, _auth: None = Depends(_require_api_key)) -> dict: + _limiter.check(_rate_limit_key(request, "w"), RATE_WRITE, request) + workflows = load_workflows_from_disk() + workflow = None + for w in workflows: + if w["id"] == workflow_id: + workflow = w + break + + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + nodes = workflow.get("nodes", []) + edges = workflow.get("edges", []) + + # Simple topological sorting helper / step generator + steps = [] + executed_nodes = set() + + # We will simulate standard step-by-step executions + # Map nodes by id for quick lookup + node_map = {n["id"]: n for n in nodes} + + # Let's find start nodes (nodes with no incoming edges) + incoming_counts = {n["id"]: 0 for n in nodes} + outgoing_edges = {n["id"]: [] for n in nodes} + for e in edges: + target = e.get("target") + source = e.get("source") + if target in incoming_counts: + incoming_counts[target] += 1 + if source in outgoing_edges: + outgoing_edges[source].append(e) + + # Queue for nodes to execute + queue = [nid for nid, count in incoming_counts.items() if count == 0] + if not queue and nodes: + # fallback if circular reference or no clear start + queue = [nodes[0]["id"]] + + step_index = 1 + while queue: + current_id = queue.pop(0) + if current_id in executed_nodes: + continue + executed_nodes.add(current_id) + node = node_map.get(current_id) + if not node: + continue + + node_type = node.get("type", "unknown") + node_name = node.get("data", {}).get("name", f"Node {current_id}") + + # Determine simulated outputs & logs based on type + outputs = {} + errors = [] + log_msgs = [] + status = "success" + + if node_type == "agent": + framework = node.get("data", {}).get("framework", "custom") + agent_name = node.get("data", {}).get("agentName", "Agent") + log_msgs = [ + f"Initializing {framework} Agent: '{agent_name}'...", + "Running Planner reasoning cycle...", + f"Agent task completed successfully." + ] + outputs = {"agent_status": "idle", "last_message": "Task processed successfully."} + elif node_type == "llm": + model = node.get("data", {}).get("model", "gpt-4o") + log_msgs = [ + f"Preparing prompt context for Model: {model}...", + f"Calling LLM API ({model})...", + "Received completion: 240 prompt tokens, 120 completion tokens." + ] + outputs = {"completion": "Simulated LLM response details", "tokens_used": 360} + elif node_type == "memory": + mode = node.get("data", {}).get("mode", "read") + key = node.get("data", {}).get("memoryKey", "default") + storage = node.get("data", {}).get("storageType", "episodic") + log_msgs = [ + f"Connecting to {storage} memory store...", + f"Performing memory {mode} for key: '{key}'...", + f"Memory operation completed successfully." + ] + outputs = {"retrieved_context": "Found 3 matching items in memory." if mode == "read" else "Wrote context."} + elif node_type == "http": + url = node.get("data", {}).get("url", "https://api.example.com") + method = node.get("data", {}).get("method", "GET") + log_msgs = [ + f"Dispatching HTTP {method} request to {url}...", + "Waiting for server response...", + "Response HTTP 200 OK received." + ] + outputs = {"response_body": {"status": "ok", "items": []}, "status_code": 200} + elif node_type == "file": + path = node.get("data", {}).get("filePath", "/workspace/data.json") + action = node.get("data", {}).get("action", "read") + log_msgs = [ + f"Opening file pointer at: {path}...", + f"Performing action '{action}' on filesystem...", + "Operation success." + ] + outputs = {"file_size_bytes": 1024, "status": "completed"} + # Let's add a warning if deleting or accessing sensitive files + if action == "delete" or "/etc" in path or "/" == path: + errors.append(f"High risk filesystem operation detected at: {path}") + elif node_type == "scheduler": + cron = node.get("data", {}).get("cron", "* * * * *") + log_msgs = [ + f"Evaluating cron schedule pattern: '{cron}'...", + "Next execution interval computed successfully." + ] + outputs = {"next_run": "2026-06-22T14:00:00Z"} + elif node_type == "conditional": + prop = node.get("data", {}).get("property", "score") + operator = node.get("data", {}).get("operator", "eq") + val = node.get("data", {}).get("value", "true") + log_msgs = [ + f"Evaluating branch condition: '{prop}' {operator} '{val}'...", + "Condition resolved to TRUE." + ] + outputs = {"branch_taken": "true"} + elif node_type == "custom-tool": + tool = node.get("data", {}).get("toolName", "custom_script") + log_msgs = [ + f"Resolving custom tool binding: '{tool}'...", + "Running sandbox simulation...", + "Tool finished with exit code 0." + ] + outputs = {"result": "success"} + else: + log_msgs = ["Executing generic workflow step..."] + + # Introduce a random failure scenario or warning for demonstrating errors + if current_id == "node-failed-test": + status = "failure" + errors.append("Simulation execution failed: Connection Timeout") + log_msgs.append("CRITICAL: Connection timed out after 5000ms.") + + steps.append({ + "step_index": step_index, + "node_id": current_id, + "node_name": node_name, + "node_type": node_type, + "status": status, + "logs": log_msgs, + "outputs": outputs, + "errors": errors, + "duration_ms": 150 + (step_index * 45) + }) + step_index += 1 + + # Add next nodes to queue + for edge in outgoing_edges[current_id]: + target = edge.get("target") + if target not in executed_nodes: + queue.append(target) + + # Return overall simulation summary and step traces + return { + "workflow_id": workflow_id, + "name": workflow.get("name"), + "status": "failure" if any(s["status"] == "failure" for s in steps) else "success", + "steps": steps, + "total_duration_ms": sum(s["duration_ms"] for s in steps), + "executed_nodes_count": len(executed_nodes) + } + + + + def _sanitize_event(event_dict: dict[str, Any]) -> dict[str, Any]: """Escape HTML tags in user-facing preview strings to prevent XSS in the dashboard. diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 00000000..085d8c8c --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,8 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "@next/next/no-html-link-for-pages": "off", + "react/no-unescaped-entities": "off", + "react-hooks/exhaustive-deps": "off" + } +} diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index 019018a5..73926d73 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -315,10 +315,16 @@ export default function DashboardPage() {
AgentWatch

Reliability, safety, and observability

- +
+ + + Workflow Builder + + +
diff --git a/frontend/pages/workflow-builder.tsx b/frontend/pages/workflow-builder.tsx new file mode 100644 index 00000000..ae23a387 --- /dev/null +++ b/frontend/pages/workflow-builder.tsx @@ -0,0 +1,1264 @@ +import React, { useState, useEffect, useRef } from 'react' +import useSWR from 'swr' +import { + Cpu, + Layers, + Link as LinkIcon, + FileText, + Calendar, + GitBranch, + Wrench, + Play, + Pause, + SkipForward, + SkipBack, + RotateCcw, + ZoomIn, + ZoomOut, + Move, + Map, + Save, + FolderOpen, + AlertTriangle, + Trash2, + Plus, + X, + CheckCircle, + HelpCircle, + Clock, + Compass, + Check +} from 'lucide-react' + +// Constants +const API_BASE = process.env.NEXT_PUBLIC_API_HOST + ? `https://${process.env.NEXT_PUBLIC_API_HOST}/api/v1` + : '/api/v1' + +const fetcher = (url: string) => fetch(url).then((r) => (r.ok ? r.json() : null)) + +// Types +interface NodeData { + name: string + [key: string]: any +} + +interface WorkflowNode { + id: string + type: string + position: { x: number; y: number } + data: NodeData +} + +interface WorkflowEdge { + id: string + source: string + target: string + source_handle?: string | null + target_handle?: string | null +} + +interface Workflow { + id: string + name: string + description?: string + nodes: WorkflowNode[] + edges: WorkflowEdge[] + created_at?: string + updated_at?: string +} + +interface SimulationStep { + step_index: number + node_id: string + node_name: string + node_type: string + status: 'success' | 'failure' | 'running' | 'pending' + logs: string[] + outputs: any + errors: string[] + duration_ms: number +} + +// Available node type configurations +const NODE_TYPES = [ + { type: 'agent', label: 'Agent Node', icon: Cpu, color: '#3b82f6', defaultData: { name: 'New Agent', framework: 'crewai', agentName: 'Researcher', systemPrompt: 'You are a helpful assistant.' } }, + { type: 'llm', label: 'LLM Node', icon: Compass, color: '#a855f7', defaultData: { name: 'LLM Synthesizer', model: 'claude-3-5-sonnet', temperature: 0.3, systemPrompt: 'Summarize the input data.' } }, + { type: 'memory', label: 'Memory Node', icon: Layers, color: '#10b981', defaultData: { name: 'Memory Store', memoryKey: 'chat_history', mode: 'read', storageType: 'semantic' } }, + { type: 'http', label: 'HTTP Request', icon: LinkIcon, color: '#f59e0b', defaultData: { name: 'API Call', url: 'https://api.example.com/v1/data', method: 'GET', headers: '{}', body: '' } }, + { type: 'file', label: 'File Processing', icon: FileText, color: '#6366f1', defaultData: { name: 'File Operation', filePath: '/workspace/input.txt', action: 'read' } }, + { type: 'scheduler', label: 'Scheduler', icon: Calendar, color: '#ec4899', defaultData: { name: 'Cron Trigger', cron: '0 9 * * 1-5', triggerEvent: 'scheduled_report' } }, + { type: 'conditional', label: 'Conditional Logic', icon: GitBranch, color: '#f97316', defaultData: { name: 'If/Else Route', property: 'status', operator: 'eq', value: 'success' } }, + { type: 'custom-tool', label: 'Custom Tool', icon: Wrench, color: '#06b6d4', defaultData: { name: 'Custom Sandbox Script', toolName: 'custom_executor', arguments: '{}' } } +] + +export default function WorkflowBuilder() { + // SWR for loading workflows from backend + const { data: workflowsList, mutate: mutateWorkflows } = useSWR(`${API_BASE}/workflows`, fetcher) + + // Canvas State + const [nodes, setNodes] = useState([]) + const [edges, setEdges] = useState([]) + const [workflowName, setWorkflowName] = useState('My Automation Pipeline') + const [workflowDesc, setWorkflowDesc] = useState('Describe your pipeline goals and structure...') + const [selectedWorkflowId, setSelectedWorkflowId] = useState(null) + + // Selection & UI State + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [isTemplatesOpen, setIsTemplatesOpen] = useState(false) + const [saveSuccess, setSaveSuccess] = useState(false) + + // Zoom & Pan + const [zoom, setZoom] = useState(1.0) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [isPanning, setIsPanning] = useState(false) + const [panStart, setPanStart] = useState({ x: 0, y: 0 }) + + // Drag node state (from palette to canvas) + const [draggedNodeType, setDraggedNodeType] = useState(null) + const [isDraggingNodeOnCanvas, setIsDraggingNodeOnCanvas] = useState(null) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + + // Connection line dragging state + const [connectingFrom, setConnectingFrom] = useState<{ nodeId: string; handle: string } | null>(null) + const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }) + + // Simulation State + const [simulationSteps, setSimulationSteps] = useState([]) + const [simulationStatus, setSimulationStatus] = useState<'idle' | 'running' | 'paused' | 'success' | 'failure'>('idle') + const [currentStepIndex, setCurrentStepIndex] = useState(-1) + const [simLogs, setSimLogs] = useState([]) + + // Refs + const canvasRef = useRef(null) + + // Load first workflow or template on mount + useEffect(() => { + if (workflowsList && workflowsList.length > 0 && !selectedWorkflowId) { + loadWorkflow(workflowsList[0]) + } + }, [workflowsList]) + + // Real-time Validation Warning checks + const getValidationWarnings = () => { + const warnings: string[] = [] + + // Check for disconnected nodes (no edges attached) + nodes.forEach(node => { + const isConnected = edges.some(e => e.source === node.id || e.target === node.id) + if (!isConnected && nodes.length > 1) { + warnings.push(`Node "${node.data.name}" is isolated and not connected to any other node.`) + } + + // Node specific field validation warnings + if (node.type === 'http' && !node.data.url) { + warnings.push(`Node "${node.data.name}" has an empty HTTP URL.`) + } + if (node.type === 'file' && !node.data.filePath) { + warnings.push(`Node "${node.data.name}" has an empty File Path.`) + } + }) + return warnings + } + + // Load a workflow template or saved workflow + const loadWorkflow = (wf: Workflow) => { + setNodes(wf.nodes || []) + setEdges(wf.edges || []) + setWorkflowName(wf.name) + setWorkflowDesc(wf.description || '') + setSelectedWorkflowId(wf.id) + setSelectedNodeId(null) + setSimulationStatus('idle') + setSimulationSteps([]) + setSimLogs(['System: Workflow loaded successfully. Ready to simulate.']) + setCurrentStepIndex(-1) + } + + // Create a new blank workflow + const handleNewWorkflow = () => { + const newId = `wf-${Math.random().toString(36).substr(2, 9)}` + setNodes([ + { id: 'node-1', type: 'scheduler', position: { x: 150, y: 220 }, data: { name: 'Schedule Trigger', cron: '0 9 * * 1-5' } } + ]) + setEdges([]) + setWorkflowName('Untitled Workflow') + setWorkflowDesc('A brand new automation pipeline') + setSelectedWorkflowId(newId) + setSelectedNodeId('node-1') + setSimulationStatus('idle') + setSimulationSteps([]) + setSimLogs(['System: New workflow created.']) + setCurrentStepIndex(-1) + } + + // Save workflow to backend + const handleSaveWorkflow = async () => { + const id = selectedWorkflowId || `wf-${Math.random().toString(36).substr(2, 9)}` + if (!selectedWorkflowId) { + setSelectedWorkflowId(id) + } + + const payload: Workflow = { + id, + name: workflowName, + description: workflowDesc, + nodes, + edges + } + + try { + const res = await fetch(`${API_BASE}/workflows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + if (res.ok) { + setSaveSuccess(true) + setTimeout(() => setSaveSuccess(false), 3000) + if (mutateWorkflows) mutateWorkflows() + } + } catch (err) { + console.error('Failed to save workflow', err) + } + } + + // Delete a workflow + const handleDeleteWorkflow = async (id: string) => { + if (confirm('Are you sure you want to delete this workflow?')) { + try { + const res = await fetch(`${API_BASE}/workflows/${id}`, { + method: 'DELETE' + }) + if (res.ok) { + if (mutateWorkflows) mutateWorkflows() + if (selectedWorkflowId === id) { + setSelectedWorkflowId(null) + } + } + } catch (err) { + console.error('Failed to delete workflow', err) + } + } + } + + // Zoom controls + const zoomIn = () => setZoom(prev => Math.min(prev + 0.1, 1.8)) + const zoomOut = () => setZoom(prev => Math.max(prev - 0.1, 0.5)) + const zoomReset = () => { + setZoom(1.0) + setPan({ x: 0, y: 0 }) + } + + // Pan controls via mouse drag on grid background + const handleMouseDownBg = (e: React.MouseEvent) => { + if (e.button !== 0) return // Left click only + setIsPanning(true) + setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }) + } + + const handleMouseMoveCanvas = (e: React.MouseEvent) => { + if (isPanning) { + setPan({ + x: e.clientX - panStart.x, + y: e.clientY - panStart.y + }) + } else if (isDraggingNodeOnCanvas && dragOffset) { + // Calculate scaled coords + const rect = canvasRef.current?.getBoundingClientRect() + if (rect) { + const clientX = e.clientX - rect.left - pan.x + const clientY = e.clientY - rect.top - pan.y + setNodes(prev => + prev.map(n => + n.id === isDraggingNodeOnCanvas + ? { ...n, position: { x: Math.round(clientX / zoom - dragOffset.x), y: Math.round(clientY / zoom - dragOffset.y) } } + : n + ) + ) + } + } else if (connectingFrom) { + const rect = canvasRef.current?.getBoundingClientRect() + if (rect) { + setCursorPos({ + x: (e.clientX - rect.left - pan.x) / zoom, + y: (e.clientY - rect.top - pan.y) / zoom + }) + } + } + } + + const handleMouseUpCanvas = () => { + setIsPanning(false) + setIsDraggingNodeOnCanvas(null) + if (connectingFrom) { + setConnectingFrom(null) + } + } + + // Node Dragging Start + const handleMouseDownNode = (e: React.MouseEvent, node: WorkflowNode) => { + e.stopPropagation() + setSelectedNodeId(node.id) + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + // Capture click offset inside the node itself to prevent jumping + setDragOffset({ + x: (e.clientX - rect.left) / zoom, + y: (e.clientY - rect.top) / zoom + }) + setIsDraggingNodeOnCanvas(node.id) + } + + // Drag and Drop from library palette + const handleDragStartFromPalette = (type: string) => { + setDraggedNodeType(type) + } + + const handleDropOnCanvas = (e: React.DragEvent) => { + e.preventDefault() + if (!draggedNodeType) return + const rect = canvasRef.current?.getBoundingClientRect() + if (rect) { + const canvasX = (e.clientX - rect.left - pan.x) / zoom + const canvasY = (e.clientY - rect.top - pan.y) / zoom + + const config = NODE_TYPES.find(t => t.type === draggedNodeType) + const newNode: WorkflowNode = { + id: `node-${Math.random().toString(36).substr(2, 9)}`, + type: draggedNodeType, + position: { x: Math.round(canvasX - 100), y: Math.round(canvasY - 40) }, + data: JSON.parse(JSON.stringify(config?.defaultData || { name: 'New Node' })) + } + setNodes(prev => [...prev, newNode]) + setSelectedNodeId(newNode.id) + } + setDraggedNodeType(null) + } + + // Connection handling + const handleStartConnection = (e: React.MouseEvent, nodeId: string, handle: string) => { + e.stopPropagation() + const rect = canvasRef.current?.getBoundingClientRect() + if (rect) { + setConnectingFrom({ nodeId, handle }) + setCursorPos({ + x: (e.clientX - rect.left - pan.x) / zoom, + y: (e.clientY - rect.top - pan.y) / zoom + }) + } + } + + const handleEndConnection = (e: React.MouseEvent, targetNodeId: string) => { + e.stopPropagation() + if (connectingFrom && connectingFrom.nodeId !== targetNodeId) { + // Create new edge + const edgeId = `edge-${Math.random().toString(36).substr(2, 9)}` + const newEdge: WorkflowEdge = { + id: edgeId, + source: connectingFrom.nodeId, + target: targetNodeId, + source_handle: connectingFrom.handle, + target_handle: 'input' + } + // Check if connection already exists to prevent duplicates + const exists = edges.some(edge => edge.source === newEdge.source && edge.target === newEdge.target) + if (!exists) { + setEdges(prev => [...prev, newEdge]) + } + } + setConnectingFrom(null) + } + + // Node Deletion + const deleteNode = (nodeId: string) => { + setNodes(prev => prev.filter(n => n.id !== nodeId)) + setEdges(prev => prev.filter(e => e.source !== nodeId && e.target !== nodeId)) + if (selectedNodeId === nodeId) setSelectedNodeId(null) + } + + // Config Update + const updateNodeData = (nodeId: string, key: string, value: any) => { + setNodes(prev => + prev.map(n => + n.id === nodeId + ? { ...n, data: { ...n.data, [key]: value } } + : n + ) + ) + } + + // Compute coordinate centers for drawing SVGs + const getNodeCoordinates = (nodeId: string, handleType: string) => { + const node = nodes.find(n => n.id === nodeId) + if (!node) return { x: 0, y: 0 } + + // Width and height approximation for node positions + const nodeWidth = 220 + const nodeHeight = 80 + + if (handleType === 'input') { + return { x: node.position.x, y: node.position.y + nodeHeight / 2 } + } else if (handleType === 'true') { + return { x: node.position.x + nodeWidth, y: node.position.y + nodeHeight / 3 } + } else if (handleType === 'false') { + return { x: node.position.x + nodeWidth, y: node.position.y + (nodeHeight / 3) * 2 } + } else { + return { x: node.position.x + nodeWidth, y: node.position.y + nodeHeight / 2 } + } + } + + // Simulation Runner Controllers + const triggerSimulation = async () => { + if (!selectedWorkflowId) return + setSimulationStatus('running') + setSimLogs(prev => [...prev, 'System: Launching pipeline execution simulation...']) + + try { + const res = await fetch(`${API_BASE}/workflows/${selectedWorkflowId}/run`, { + method: 'POST' + }) + if (res.ok) { + const simResult = await res.json() + setSimulationSteps(simResult.steps || []) + if (simResult.steps && simResult.steps.length > 0) { + setCurrentStepIndex(0) + setSimLogs(prev => [ + ...prev, + ...simResult.steps[0].logs.map((l: string) => `[Node: ${simResult.steps[0].node_name}] ${l}`) + ]) + } else { + setSimulationStatus('success') + } + } else { + setSimulationStatus('failure') + setSimLogs(prev => [...prev, 'CRITICAL: Simulation failed to bootstrap on the server backend.']) + } + } catch (e) { + setSimulationStatus('failure') + setSimLogs(prev => [...prev, 'CRITICAL: Offline mode — simulation error.']) + } + } + + // Step controls + const stepForward = () => { + if (currentStepIndex < simulationSteps.length - 1) { + const nextIndex = currentStepIndex + 1 + setCurrentStepIndex(nextIndex) + const nextStep = simulationSteps[nextIndex] + setSimLogs(prev => [ + ...prev, + ...nextStep.logs.map((l: string) => `[Node: ${nextStep.node_name}] ${l}`) + ]) + if (nextStep.status === 'failure') { + setSimulationStatus('failure') + } else if (nextIndex === simulationSteps.length - 1) { + setSimulationStatus('success') + } + } + } + + const stepBackward = () => { + if (currentStepIndex > 0) { + setCurrentStepIndex(prev => prev - 1) + setSimLogs(prev => [...prev, 'System: Reverting execution step back.']) + setSimulationStatus('running') + } + } + + const resetSimulation = () => { + setSimulationStatus('idle') + setCurrentStepIndex(-1) + setSimulationSteps([]) + setSimLogs(['System: Simulation reset. Ready to launch.']) + } + + // Current active node in simulation + const activeNodeId = currentStepIndex >= 0 && currentStepIndex < simulationSteps.length + ? simulationSteps[currentStepIndex].node_id + : null + + // Check state of nodes in simulation + const getNodeSimulationStatus = (nodeId: string) => { + if (simulationStatus === 'idle') return null + + // Find if node has completed or is active + const step = simulationSteps.find(s => s.node_id === nodeId) + if (!step) return 'pending' + + const stepIdx = simulationSteps.indexOf(step) + if (stepIdx === currentStepIndex) return 'running' + if (stepIdx < currentStepIndex) return step.status // success or failure + return 'pending' + } + + return ( +
+ {/* Top Header Navigation */} +
+
+ +
+
+ setWorkflowName(e.target.value)} + className="bg-transparent text-lg font-semibold text-white border-b border-transparent hover:border-white/20 focus:border-blue-500 focus:outline-none px-1" + placeholder="Workflow Name" + /> + + PIPELINE + +
+ setWorkflowDesc(e.target.value)} + className="bg-transparent text-xs text-zinc-400 focus:outline-none w-80 truncate" + placeholder="Add description..." + /> +
+
+ + {/* Action Controls */} +
+ + + + + + Dashboard + +
+
+ + {/* Main Builder Pane */} +
+ {/* Templates Slide Panel Overlay */} + {isTemplatesOpen && ( +
+
+

Pipeline Templates

+ +
+ +
+
+ +
+ + {workflowsList?.map(wf => ( +
+
+

{wf.name}

+ +
+

{wf.description}

+ +
+ ))} +
+
+ )} + + {/* Left Toolbar: Node Palette & Templates list */} + + + {/* Center: Dynamic Workflow Canvas Grid */} +
e.preventDefault()} + onDrop={handleDropOnCanvas} + onMouseMove={handleMouseMoveCanvas} + onMouseUp={handleMouseUpCanvas} + onMouseDown={handleMouseDownBg} + className="flex-1 h-full relative overflow-hidden bg-[#070913]" + style={{ cursor: isPanning ? 'grabbing' : connectingFrom ? 'crosshair' : 'default' }} + > + {/* Subtle Canvas SVG Grid lines */} +
+ + {/* SVG Connection Layer */} + + {/* Draw permanent edges */} + {edges.map(edge => { + const start = getNodeCoordinates(edge.source, edge.source_handle || 'output') + const end = getNodeCoordinates(edge.target, 'input') + + // Compute smooth bezier curve + const dx = Math.abs(end.x - start.x) * 0.5 + const path = `M ${start.x} ${start.y} C ${start.x + dx} ${start.y}, ${end.x - dx} ${end.y}, ${end.x} ${end.y}` + + const isSimulatingActive = activeNodeId === edge.source + + return ( + + + {isSimulatingActive && ( + + + + )} + {/* Small delete click target for edge */} + { + e.stopPropagation() + if (confirm('Delete this connection?')) { + setEdges(prev => prev.filter(edgeItem => edgeItem.id !== edge.id)) + } + }} + /> + + ) + })} + + {/* Draw temporary dragging edge */} + {connectingFrom && ( + + )} + + + {/* HTML Nodes Layer */} +
+ {nodes.map(node => { + const config = NODE_TYPES.find(t => t.type === node.type) + const Icon = config?.icon || HelpCircle + const isSelected = selectedNodeId === node.id + const simStatus = getNodeSimulationStatus(node.id) + + // Get style variables based on simulation status + let borderColor = 'border-white/10' + let shadowStyle = '' + if (isSelected) { + borderColor = 'border-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.3)]' + } else if (simStatus === 'running') { + borderColor = 'border-amber-400' + shadowStyle = 'shadow-[0_0_20px_rgba(245,158,11,0.5)] animate-pulse' + } else if (simStatus === 'success') { + borderColor = 'border-emerald-500' + shadowStyle = 'shadow-[0_0_12px_rgba(16,185,129,0.2)]' + } else if (simStatus === 'failure') { + borderColor = 'border-red-500' + shadowStyle = 'shadow-[0_0_20px_rgba(239,68,68,0.5)]' + } + + return ( +
handleMouseDownNode(e, node)} + onMouseUp={(e) => handleEndConnection(e, node.id)} + style={{ left: node.position.x, top: node.position.y }} + className={`absolute w-56 bg-zinc-900/90 backdrop-blur rounded-xl border ${borderColor} ${shadowStyle} select-none transition-shadow p-3.5 flex flex-col justify-between cursor-move hover:bg-zinc-900 group`} + > + {/* Top line details */} +
+
+
+ +
+
+
{node.data.name}
+
{node.type}
+
+
+ {/* Node status indicators */} +
+ {simStatus === 'success' && } + {simStatus === 'running' && } + {simStatus === 'failure' && } + +
+
+ + {/* Handles */} + {/* Input handle (left side) */} +
+ + {/* Output handles (right side) */} + {node.type === 'conditional' ? ( + <> + {/* True handle */} +
handleStartConnection(e, node.id, 'true')} + className="absolute -right-1.5 top-1/3 -translate-y-1/2 w-3 h-3 rounded-full bg-emerald-500 border-2 border-zinc-950 hover:bg-emerald-400 transition cursor-crosshair z-30" + title="True Branch" + /> + {/* False handle */} +
handleStartConnection(e, node.id, 'false')} + className="absolute -right-1.5 top-2/3 -translate-y-1/2 w-3 h-3 rounded-full bg-red-500 border-2 border-zinc-950 hover:bg-red-400 transition cursor-crosshair z-30" + title="False Branch" + /> + + ) : ( +
handleStartConnection(e, node.id, 'output')} + className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-zinc-950 border-2 border-white/20 hover:border-blue-500 transition cursor-crosshair z-30" + title="Output Port" + /> + )} +
+ ) + })} +
+ + {/* Minimap Overlay (bottom-right of canvas) */} +
+
+ Minimap +
+
+ {/* Scale mini-nodes into preview box */} + {nodes.map(n => ( +
+ ))} +
+
+
+ + {/* Right Sidebar: Selected Node Editor Form */} +