feat(workflows): add full Workflow section — visual constructor, dashboard & execution engine
## New Feature: Workflow Builder & Execution Engine ### Database Schema (4 new tables) - workflows: pipeline definitions with status (draft/active/paused/archived), tags, canvas metadata - workflowNodes: agent/container/trigger/condition/output blocks with canvas positions - workflowEdges: directional connections between nodes (source→target) - workflowRuns: execution history with per-node status tracking & timing ### Backend (server/workflows.ts + 13 tRPC endpoints in routers.ts) - Full CRUD for workflows, nodes, edges - Atomic canvas save (nodes + edges in one mutation) - BFS graph execution engine: walks from trigger nodes, executes agents/containers in order - Single-node test execution for individual block testing - Run management: start, cancel, poll status, list history - Aggregated workflow stats (success rate, avg duration, run counts) ### Frontend — Visual Constructor - WorkflowCanvas: interactive drag-and-drop builder with: - Node palette sidebar (trigger/agent/container/condition/output types) - Agent list for quick drag-to-canvas agent nodes - Edge drawing between output→input ports with bezier curves - Pan/zoom controls + grid background - Keyboard shortcuts (Delete, Ctrl+S) - Real-time run status overlays (running/success/failed per node) - WorkflowNodeBlock: kind-aware visual cards with status indicators & connection ports - WorkflowNodeEditModal: per-kind configuration (agent selector, Docker image/env, condition expressions, cron/webhook triggers) - WorkflowCreateModal: create new workflows with name, description, tags - WorkflowDashboard: monitoring panel with stats cards, run history timeline, per-node progress bars - Workflows page: unified list/canvas/dashboard views with tabs ### Navigation & Routing - Added Workflows nav item (GitBranch icon) in sidebar between Agents and Tools - Routes: /workflows (list), /workflows/:id (dashboard+canvas) ### Also includes - fix(nodes): keep AddNodeDialog open after join + canJoin guard
This commit is contained in:
@@ -13,6 +13,7 @@ import Settings from "./pages/Settings";
|
||||
import Nodes from "./pages/Nodes";
|
||||
import Tools from "./pages/Tools";
|
||||
import Skills from "./pages/Skills";
|
||||
import Workflows from "./pages/Workflows";
|
||||
|
||||
function Router() {
|
||||
// make sure to consider if you need authentication for certain routes
|
||||
@@ -26,6 +27,8 @@ function Router() {
|
||||
<Route path="/chat" component={Chat} />
|
||||
<Route path="/tools" component={Tools} />
|
||||
<Route path="/skills" component={Skills} />
|
||||
<Route path="/workflows" component={Workflows} />
|
||||
<Route path="/workflows/:id" component={Workflows} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Wifi,
|
||||
Wrench,
|
||||
Zap,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@@ -28,6 +29,7 @@ import { trpc } from "@/lib/trpc";
|
||||
const NAV_ITEMS = [
|
||||
{ path: "/", icon: LayoutDashboard, label: "Дашборд" },
|
||||
{ path: "/agents", icon: Bot, label: "Агенты" },
|
||||
{ path: "/workflows", icon: GitBranch, label: "Воркфлоу" },
|
||||
{ path: "/tools", icon: Wrench, label: "Инструменты" },
|
||||
{ path: "/skills", icon: Zap, label: "Скилы" },
|
||||
{ path: "/nodes", icon: Server, label: "Ноды" },
|
||||
|
||||
554
client/src/components/WorkflowCanvas.tsx
Normal file
554
client/src/components/WorkflowCanvas.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* WorkflowCanvas — interactive visual constructor for building workflows.
|
||||
* Supports:
|
||||
* - Drag-and-drop nodes from palette
|
||||
* - Drawing edges between ports
|
||||
* - Selecting/deleting nodes and edges
|
||||
* - Panning/zooming the canvas
|
||||
* - Save to server via tRPC
|
||||
* - Real-time run status overlays
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Save,
|
||||
Play,
|
||||
Trash2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Loader2,
|
||||
X,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { nanoid } from "nanoid";
|
||||
import { WorkflowNodeBlock, WorkflowNodePaletteItem, type WFNodeData, type NodeKind } from "./WorkflowNodeBlock";
|
||||
import { WorkflowNodeEditModal } from "./WorkflowNodeEditModal";
|
||||
|
||||
export interface WFEdgeData {
|
||||
edgeKey: string;
|
||||
sourceNodeKey: string;
|
||||
targetNodeKey: string;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
label?: string;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface WorkflowCanvasProps {
|
||||
workflowId: number;
|
||||
workflowName: string;
|
||||
initialNodes?: WFNodeData[];
|
||||
initialEdges?: WFEdgeData[];
|
||||
/** Run results overlay: nodeKey → status */
|
||||
runResults?: Record<string, {
|
||||
status: "pending" | "running" | "success" | "failed" | "skipped";
|
||||
output?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function WorkflowCanvas({
|
||||
workflowId,
|
||||
workflowName,
|
||||
initialNodes = [],
|
||||
initialEdges = [],
|
||||
runResults,
|
||||
onBack,
|
||||
}: WorkflowCanvasProps) {
|
||||
const [nodes, setNodes] = useState<WFNodeData[]>(initialNodes);
|
||||
const [edges, setEdges] = useState<WFEdgeData[]>(initialEdges);
|
||||
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
|
||||
const [selectedEdgeKey, setSelectedEdgeKey] = useState<string | null>(null);
|
||||
const [editingNode, setEditingNode] = useState<WFNodeData | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Dragging state
|
||||
const [dragging, setDragging] = useState<{ nodeKey: string; offsetX: number; offsetY: number } | null>(null);
|
||||
|
||||
// Edge drawing state
|
||||
const [edgeDrawing, setEdgeDrawing] = useState<{
|
||||
sourceKey: string;
|
||||
sourcePortType: "input" | "output";
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
} | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch agents for the node editor
|
||||
const { data: agents = [] } = trpc.agents.list.useQuery();
|
||||
|
||||
// Save canvas mutation
|
||||
const saveMutation = trpc.workflows.saveCanvas.useMutation({
|
||||
onSuccess: () => toast.success("Canvas saved"),
|
||||
onError: (e) => toast.error(`Save failed: ${e.message}`),
|
||||
});
|
||||
|
||||
// Execute workflow mutation
|
||||
const executeMutation = trpc.workflows.execute.useMutation({
|
||||
onSuccess: (run) => {
|
||||
toast.success(`Workflow started: ${run?.runKey ?? "?"}`);
|
||||
},
|
||||
onError: (e) => toast.error(`Execution failed: ${e.message}`),
|
||||
});
|
||||
|
||||
// Test single node
|
||||
const testNodeMutation = trpc.workflows.executeNode.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) toast.success(`Node executed in ${result.durationMs}ms`);
|
||||
else toast.error(`Node failed: ${result.error}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Apply run results as status overlays
|
||||
const nodesWithStatus: WFNodeData[] = nodes.map((n) => {
|
||||
const rr = runResults?.[n.nodeKey];
|
||||
if (!rr) return n;
|
||||
return { ...n, runStatus: rr.status, runDurationMs: rr.durationMs, runError: rr.error };
|
||||
});
|
||||
|
||||
// ─── Canvas event handlers ───────────────────────────────────────────────
|
||||
|
||||
const handleCanvasMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.target === canvasRef.current || (e.target as HTMLElement).dataset.canvas) {
|
||||
setSelectedNodeKey(null);
|
||||
setSelectedEdgeKey(null);
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
||||
// Panning
|
||||
if (isPanning) {
|
||||
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
|
||||
}
|
||||
|
||||
// Node dragging
|
||||
if (dragging && canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const newX = (e.clientX - rect.left - pan.x) / zoom - dragging.offsetX;
|
||||
const newY = (e.clientY - rect.top - pan.y) / zoom - dragging.offsetY;
|
||||
setNodes((prev) =>
|
||||
prev.map((n) =>
|
||||
n.nodeKey === dragging.nodeKey
|
||||
? { ...n, posX: Math.round(newX), posY: Math.round(newY) }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Edge drawing
|
||||
if (edgeDrawing && canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
setEdgeDrawing({
|
||||
...edgeDrawing,
|
||||
mouseX: (e.clientX - rect.left - pan.x) / zoom,
|
||||
mouseY: (e.clientY - rect.top - pan.y) / zoom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = () => {
|
||||
setIsPanning(false);
|
||||
setDragging(null);
|
||||
setEdgeDrawing(null);
|
||||
};
|
||||
|
||||
const handleNodeDragStart = (nodeKey: string, e: React.MouseEvent) => {
|
||||
if (canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const node = nodes.find((n) => n.nodeKey === nodeKey);
|
||||
if (!node) return;
|
||||
const offsetX = (e.clientX - rect.left - pan.x) / zoom - node.posX;
|
||||
const offsetY = (e.clientY - rect.top - pan.y) / zoom - node.posY;
|
||||
setDragging({ nodeKey, offsetX, offsetY });
|
||||
}
|
||||
};
|
||||
|
||||
// Port connection
|
||||
const handlePortMouseDown = (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => {
|
||||
if (canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
setEdgeDrawing({
|
||||
sourceKey: nodeKey,
|
||||
sourcePortType: portType,
|
||||
mouseX: (e.clientX - rect.left - pan.x) / zoom,
|
||||
mouseY: (e.clientY - rect.top - pan.y) / zoom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When mouse up on a port — create an edge
|
||||
const handlePortMouseUp = useCallback((nodeKey: string, portType: "input" | "output") => {
|
||||
if (!edgeDrawing) return;
|
||||
// Ensure we're connecting output→input (or input→output)
|
||||
if (edgeDrawing.sourceKey === nodeKey) return;
|
||||
const sourceKey = edgeDrawing.sourcePortType === "output" ? edgeDrawing.sourceKey : nodeKey;
|
||||
const targetKey = edgeDrawing.sourcePortType === "output" ? nodeKey : edgeDrawing.sourceKey;
|
||||
|
||||
// Prevent duplicates
|
||||
const exists = edges.some((e) => e.sourceNodeKey === sourceKey && e.targetNodeKey === targetKey);
|
||||
if (exists) return;
|
||||
|
||||
setEdges((prev) => [
|
||||
...prev,
|
||||
{
|
||||
edgeKey: `edge_${nanoid(8)}`,
|
||||
sourceNodeKey: sourceKey,
|
||||
targetNodeKey: targetKey,
|
||||
},
|
||||
]);
|
||||
setEdgeDrawing(null);
|
||||
}, [edgeDrawing, edges]);
|
||||
|
||||
// Override port mouse-down to also listen for mouse-up (connection target)
|
||||
const handlePortInteraction = (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => {
|
||||
if (edgeDrawing) {
|
||||
handlePortMouseUp(nodeKey, portType);
|
||||
} else {
|
||||
handlePortMouseDown(nodeKey, portType, e);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Palette drop ─────────────────────────────────────────────────────────
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const kind = e.dataTransfer.getData("nodeKind") as NodeKind;
|
||||
if (!kind || !canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const posX = Math.round((e.clientX - rect.left - pan.x) / zoom - 110);
|
||||
const posY = Math.round((e.clientY - rect.top - pan.y) / zoom - 30);
|
||||
|
||||
const newNode: WFNodeData = {
|
||||
nodeKey: `node_${nanoid(8)}`,
|
||||
label: `New ${kind.charAt(0).toUpperCase() + kind.slice(1)}`,
|
||||
kind,
|
||||
posX,
|
||||
posY,
|
||||
};
|
||||
setNodes((prev) => [...prev, newNode]);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault();
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
const handleDeleteNode = (nodeKey: string) => {
|
||||
setNodes((prev) => prev.filter((n) => n.nodeKey !== nodeKey));
|
||||
setEdges((prev) => prev.filter((e) => e.sourceNodeKey !== nodeKey && e.targetNodeKey !== nodeKey));
|
||||
setSelectedNodeKey(null);
|
||||
};
|
||||
|
||||
const handleDeleteEdge = (edgeKey: string) => {
|
||||
setEdges((prev) => prev.filter((e) => e.edgeKey !== edgeKey));
|
||||
setSelectedEdgeKey(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate({
|
||||
workflowId,
|
||||
nodes: nodes.map((n) => ({
|
||||
nodeKey: n.nodeKey,
|
||||
label: n.label,
|
||||
kind: n.kind,
|
||||
agentId: n.agentId ?? null,
|
||||
containerConfig: n.containerConfig ?? {},
|
||||
conditionExpr: n.conditionExpr,
|
||||
triggerConfig: n.triggerConfig ?? {},
|
||||
posX: n.posX,
|
||||
posY: n.posY,
|
||||
meta: n.meta ?? {},
|
||||
})),
|
||||
edges: edges.map((e) => ({
|
||||
edgeKey: e.edgeKey,
|
||||
sourceNodeKey: e.sourceNodeKey,
|
||||
targetNodeKey: e.targetNodeKey,
|
||||
sourceHandle: e.sourceHandle,
|
||||
targetHandle: e.targetHandle,
|
||||
label: e.label,
|
||||
meta: e.meta ?? {},
|
||||
})),
|
||||
canvasMeta: { zoom, viewportX: pan.x, viewportY: pan.y },
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
executeMutation.mutate({ workflowId, input: "" });
|
||||
};
|
||||
|
||||
const handleNodeSave = (updated: WFNodeData) => {
|
||||
setNodes((prev) => prev.map((n) => (n.nodeKey === updated.nodeKey ? updated : n)));
|
||||
setEditingNode(null);
|
||||
};
|
||||
|
||||
// ─── Edge rendering helpers ───────────────────────────────────────────────
|
||||
|
||||
const getNodeCenter = (nodeKey: string, portType: "top" | "bottom") => {
|
||||
const node = nodes.find((n) => n.nodeKey === nodeKey);
|
||||
if (!node) return { x: 0, y: 0 };
|
||||
return {
|
||||
x: node.posX + 110, // half of 220px width
|
||||
y: portType === "top" ? node.posY : node.posY + 80, // approximate height
|
||||
};
|
||||
};
|
||||
|
||||
const buildEdgePath = (sx: number, sy: number, tx: number, ty: number) => {
|
||||
const midY = (sy + ty) / 2;
|
||||
return `M ${sx} ${sy} C ${sx} ${midY}, ${tx} ${midY}, ${tx} ${ty}`;
|
||||
};
|
||||
|
||||
// ─── Keyboard shortcuts ───────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (selectedNodeKey) handleDeleteNode(selectedNodeKey);
|
||||
if (selectedEdgeKey) handleDeleteEdge(selectedEdgeKey);
|
||||
}
|
||||
if (e.key === "s" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [selectedNodeKey, selectedEdgeKey, nodes, edges]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50 bg-sidebar">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="ghost" onClick={onBack} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-4 h-4 mr-1" /> Close
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border/50" />
|
||||
<span className="text-sm font-semibold text-foreground">{workflowName}</span>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{nodes.length} nodes · {edges.length} edges
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.min(z + 0.1, 2))} className="h-7 w-7 p-0">
|
||||
<ZoomIn className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-10 text-center">{Math.round(zoom * 100)}%</span>
|
||||
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.max(z - 0.1, 0.3))} className="h-7 w-7 p-0">
|
||||
<ZoomOut className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }} className="h-7 w-7 p-0">
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border/50" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Save className="w-3.5 h-3.5 mr-1" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={executeMutation.isPending || nodes.length === 0}
|
||||
className="bg-neon-green/15 text-neon-green border border-neon-green/30 hover:bg-neon-green/25"
|
||||
>
|
||||
{executeMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Play className="w-3.5 h-3.5 mr-1" />}
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar palette */}
|
||||
<div className="w-52 border-r border-border/50 bg-sidebar p-3 space-y-2 overflow-y-auto shrink-0">
|
||||
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mb-2">Node Palette</div>
|
||||
{(["trigger", "agent", "container", "condition", "output"] as NodeKind[]).map((kind) => (
|
||||
<div
|
||||
key={kind}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("nodeKind", kind)}
|
||||
>
|
||||
<WorkflowNodePaletteItem kind={kind} onDragStart={() => {}} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Quick agent list */}
|
||||
{agents.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mt-4 mb-2">Available Agents</div>
|
||||
{agents.slice(0, 10).map((agent: any) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded text-[11px] font-mono bg-primary/5 border border-primary/20 cursor-grab text-foreground hover:bg-primary/10"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("nodeKind", "agent");
|
||||
e.dataTransfer.setData("agentId", String(agent.id));
|
||||
e.dataTransfer.setData("agentName", agent.name);
|
||||
}}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-primary" />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Canvas area */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
data-canvas="true"
|
||||
className="flex-1 relative overflow-hidden bg-[#0A0E1A] cursor-default"
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
onMouseLeave={handleCanvasMouseUp}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const kind = e.dataTransfer.getData("nodeKind") as NodeKind;
|
||||
if (!kind || !canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const posX = Math.round((e.clientX - rect.left - pan.x) / zoom - 110);
|
||||
const posY = Math.round((e.clientY - rect.top - pan.y) / zoom - 30);
|
||||
|
||||
const agentIdStr = e.dataTransfer.getData("agentId");
|
||||
const agentName = e.dataTransfer.getData("agentName");
|
||||
|
||||
const newNode: WFNodeData = {
|
||||
nodeKey: `node_${nanoid(8)}`,
|
||||
label: agentName || `New ${kind.charAt(0).toUpperCase() + kind.slice(1)}`,
|
||||
kind,
|
||||
agentId: agentIdStr ? Number(agentIdStr) : undefined,
|
||||
agentName: agentName || undefined,
|
||||
posX,
|
||||
posY,
|
||||
};
|
||||
setNodes((prev) => [...prev, newNode]);
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* Grid pattern */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-20">
|
||||
<defs>
|
||||
<pattern id="grid" width={20 * zoom} height={20 * zoom} patternUnits="userSpaceOnUse" x={pan.x % (20 * zoom)} y={pan.y % (20 * zoom)}>
|
||||
<circle cx="1" cy="1" r="1" fill="#334155" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Transform container */}
|
||||
<div
|
||||
className="absolute origin-top-left"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
|
||||
>
|
||||
{/* Edges SVG */}
|
||||
<svg className="absolute inset-0 pointer-events-none" style={{ width: 5000, height: 5000, overflow: "visible" }}>
|
||||
{edges.map((edge) => {
|
||||
const src = getNodeCenter(edge.sourceNodeKey, "bottom");
|
||||
const tgt = getNodeCenter(edge.targetNodeKey, "top");
|
||||
const isSelected = selectedEdgeKey === edge.edgeKey;
|
||||
return (
|
||||
<g key={edge.edgeKey}>
|
||||
{/* Hit area (wider invisible stroke for clicking) */}
|
||||
<path
|
||||
d={buildEdgePath(src.x, src.y, tgt.x, tgt.y)}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={12}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedEdgeKey(edge.edgeKey); setSelectedNodeKey(null); }}
|
||||
/>
|
||||
<path
|
||||
d={buildEdgePath(src.x, src.y, tgt.x, tgt.y)}
|
||||
fill="none"
|
||||
stroke={isSelected ? "#00D4FF" : "#334155"}
|
||||
strokeWidth={isSelected ? 2.5 : 2}
|
||||
strokeDasharray={isSelected ? "none" : "none"}
|
||||
markerEnd=""
|
||||
/>
|
||||
{/* Arrow marker */}
|
||||
<circle cx={tgt.x} cy={tgt.y} r={3} fill={isSelected ? "#00D4FF" : "#334155"} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Edge being drawn */}
|
||||
{edgeDrawing && (() => {
|
||||
const srcNode = nodes.find((n) => n.nodeKey === edgeDrawing.sourceKey);
|
||||
if (!srcNode) return null;
|
||||
const sx = srcNode.posX + 110;
|
||||
const sy = edgeDrawing.sourcePortType === "output" ? srcNode.posY + 80 : srcNode.posY;
|
||||
return (
|
||||
<path
|
||||
d={buildEdgePath(sx, sy, edgeDrawing.mouseX, edgeDrawing.mouseY)}
|
||||
fill="none"
|
||||
stroke="#00D4FF"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="6 3"
|
||||
opacity={0.7}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{/* Nodes */}
|
||||
{nodesWithStatus.map((node) => (
|
||||
<WorkflowNodeBlock
|
||||
key={node.nodeKey}
|
||||
node={node}
|
||||
selected={selectedNodeKey === node.nodeKey}
|
||||
onSelect={() => { setSelectedNodeKey(node.nodeKey); setSelectedEdgeKey(null); }}
|
||||
onDelete={() => handleDeleteNode(node.nodeKey)}
|
||||
onEdit={() => setEditingNode(node)}
|
||||
onDragStart={(e) => handleNodeDragStart(node.nodeKey, e)}
|
||||
onPortMouseDown={handlePortInteraction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground/50 font-mono">
|
||||
Drag nodes from the palette to start building your workflow
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node edit modal */}
|
||||
{editingNode && (
|
||||
<WorkflowNodeEditModal
|
||||
node={editingNode}
|
||||
agents={agents}
|
||||
open={!!editingNode}
|
||||
onOpenChange={(open) => { if (!open) setEditingNode(null); }}
|
||||
onSave={handleNodeSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
client/src/components/WorkflowCreateModal.tsx
Normal file
144
client/src/components/WorkflowCreateModal.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* WorkflowCreateModal — create a new workflow (name + description + tags).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, GitBranch, Loader2, X } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WorkflowCreateModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (workflow: any) => void;
|
||||
}
|
||||
|
||||
export function WorkflowCreateModal({ open, onOpenChange, onSuccess }: WorkflowCreateModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
|
||||
const createMutation = trpc.workflows.create.useMutation({
|
||||
onSuccess: (wf) => {
|
||||
toast.success(`Workflow "${wf?.name}" created`);
|
||||
onSuccess(wf);
|
||||
handleReset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setTags([]);
|
||||
setTagInput("");
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
const trimmed = tagInput.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
setTags([...tags, trimmed]);
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Workflow name is required");
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({ name: name.trim(), description: description.trim() || undefined, tags });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleReset(); onOpenChange(v); }}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<GitBranch className="w-5 h-5 text-primary" />
|
||||
New Workflow
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Name *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Content Pipeline"
|
||||
className="mt-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Description</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this workflow do?"
|
||||
className="mt-1 min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Tags</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(); } }}
|
||||
placeholder="Add tag..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddTag} className="shrink-0">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-[10px] font-mono bg-primary/10 text-primary border-primary/20 gap-1">
|
||||
{tag}
|
||||
<X className="w-2.5 h-2.5 cursor-pointer hover:text-neon-red" onClick={() => handleRemoveTag(tag)} />
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border/30">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !name.trim()}
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Plus className="w-3.5 h-3.5 mr-1" />}
|
||||
Create Workflow
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
233
client/src/components/WorkflowDashboard.tsx
Normal file
233
client/src/components/WorkflowDashboard.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* WorkflowDashboard — monitoring panel for a single workflow.
|
||||
* Shows: stats overview, run history, per-node results, real-time polling.
|
||||
*/
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Ban,
|
||||
SkipForward,
|
||||
BarChart2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; bg: string; icon: any }> = {
|
||||
pending: { color: "text-muted-foreground", bg: "bg-muted/15", icon: Clock },
|
||||
running: { color: "text-primary", bg: "bg-primary/15", icon: Loader2 },
|
||||
success: { color: "text-neon-green", bg: "bg-neon-green/15", icon: CheckCircle },
|
||||
failed: { color: "text-neon-red", bg: "bg-neon-red/15", icon: XCircle },
|
||||
cancelled: { color: "text-neon-amber", bg: "bg-neon-amber/15", icon: Ban },
|
||||
skipped: { color: "text-muted-foreground", bg: "bg-muted/15", icon: SkipForward },
|
||||
};
|
||||
|
||||
interface WorkflowDashboardProps {
|
||||
workflowId: number;
|
||||
workflowName: string;
|
||||
onOpenCanvas: () => void;
|
||||
}
|
||||
|
||||
export default function WorkflowDashboard({ workflowId, workflowName, onOpenCanvas }: WorkflowDashboardProps) {
|
||||
// Stats
|
||||
const { data: stats, isLoading: statsLoading } = trpc.workflows.stats.useQuery(
|
||||
{ workflowId },
|
||||
{ refetchInterval: 10_000 }
|
||||
);
|
||||
|
||||
// Runs
|
||||
const { data: runs = [], isLoading: runsLoading, refetch: refetchRuns } = trpc.workflows.listRuns.useQuery(
|
||||
{ workflowId, limit: 20 },
|
||||
{ refetchInterval: 5_000 }
|
||||
);
|
||||
|
||||
// Execute
|
||||
const executeMutation = trpc.workflows.execute.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Workflow run started");
|
||||
refetchRuns();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// Cancel
|
||||
const cancelMutation = trpc.workflows.cancelRun.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Run cancelled");
|
||||
refetchRuns();
|
||||
},
|
||||
});
|
||||
|
||||
const formatDuration = (ms?: number | null) => {
|
||||
if (!ms) return "—";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const formatTime = (d: any) => {
|
||||
if (!d) return "—";
|
||||
return new Date(d).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-foreground">{workflowName}</h3>
|
||||
<p className="text-xs text-muted-foreground font-mono">Workflow Dashboard · Real-time monitoring</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onOpenCanvas}>
|
||||
Open Canvas
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => executeMutation.mutate({ workflowId })}
|
||||
disabled={executeMutation.isPending}
|
||||
className="bg-neon-green/15 text-neon-green border border-neon-green/30 hover:bg-neon-green/25"
|
||||
>
|
||||
{executeMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Play className="w-3.5 h-3.5 mr-1" />}
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<StatsCard label="Total Runs" value={statsLoading ? "..." : String(stats?.totalRuns ?? 0)} color="text-primary" icon={Activity} />
|
||||
<StatsCard label="Success" value={statsLoading ? "..." : String(stats?.successRuns ?? 0)} color="text-neon-green" icon={CheckCircle} />
|
||||
<StatsCard label="Failed" value={statsLoading ? "..." : String(stats?.failedRuns ?? 0)} color="text-neon-red" icon={XCircle} />
|
||||
<StatsCard label="Success Rate" value={statsLoading ? "..." : `${stats?.successRate ?? 0}%`} color="text-primary" icon={BarChart2} />
|
||||
<StatsCard label="Avg Duration" value={statsLoading ? "..." : formatDuration(stats?.avgDurationMs)} color="text-neon-amber" icon={Clock} />
|
||||
</div>
|
||||
|
||||
{/* Run history */}
|
||||
<Card className="bg-card border-border/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
Run History
|
||||
<span className="ml-auto text-[10px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<RefreshCw className="w-3 h-3" /> 5s
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
|
||||
<span className="text-xs font-mono text-muted-foreground">Loading runs...</span>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
|
||||
<Activity className="w-5 h-5 text-muted-foreground/30" />
|
||||
<span className="text-xs font-mono">No runs yet</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run: any, i: number) => {
|
||||
const sc = STATUS_CONFIG[run.status] ?? STATUS_CONFIG.pending;
|
||||
const StatusIcon = sc.icon;
|
||||
const nodeResults = (run.nodeResults ?? {}) as Record<string, any>;
|
||||
const nodeCount = Object.keys(nodeResults).length;
|
||||
const successNodes = Object.values(nodeResults).filter((r: any) => r.status === "success").length;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={run.runKey}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.03 }}
|
||||
className={`p-3 rounded-md ${sc.bg} border border-border/30`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`w-4 h-4 ${sc.color} ${run.status === "running" ? "animate-spin" : ""}`} />
|
||||
<span className="text-xs font-mono font-medium text-foreground">{run.runKey}</span>
|
||||
<Badge variant="outline" className={`text-[9px] font-mono ${sc.color}`}>
|
||||
{run.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{run.status === "running" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px] text-neon-red hover:bg-neon-red/10"
|
||||
onClick={() => cancelMutation.mutate({ runKey: run.runKey })}
|
||||
>
|
||||
<Ban className="w-3 h-3 mr-1" /> Cancel
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{formatTime(run.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node progress */}
|
||||
{nodeCount > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-mono text-muted-foreground">Nodes:</span>
|
||||
<span className="text-[10px] font-mono text-foreground">{successNodes}/{nodeCount}</span>
|
||||
</div>
|
||||
<Progress value={nodeCount > 0 ? (successNodes / nodeCount) * 100 : 0} className="h-1.5" />
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{Object.entries(nodeResults).map(([key, result]: [string, any]) => {
|
||||
const nsc = STATUS_CONFIG[result.status] ?? STATUS_CONFIG.pending;
|
||||
return (
|
||||
<Badge key={key} variant="outline" className={`text-[8px] font-mono ${nsc.color} px-1.5 py-0`}>
|
||||
{key.replace("node_", "").slice(0, 8)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration & error */}
|
||||
<div className="flex items-center gap-4 text-[10px] font-mono text-muted-foreground">
|
||||
{run.totalDurationMs && (
|
||||
<span>Duration: <span className="text-foreground">{formatDuration(run.totalDurationMs)}</span></span>
|
||||
)}
|
||||
{run.currentNodeKey && run.status === "running" && (
|
||||
<span>Current: <span className="text-primary">{run.currentNodeKey}</span></span>
|
||||
)}
|
||||
</div>
|
||||
{run.errorMessage && (
|
||||
<div className="text-[10px] font-mono text-neon-red mt-1 truncate">{run.errorMessage}</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsCard({ label, value, color, icon: Icon }: { label: string; value: string; color: string; icon: any }) {
|
||||
return (
|
||||
<Card className="bg-card border-border/50">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Icon className={`w-3.5 h-3.5 ${color}`} />
|
||||
<span className="text-[10px] font-mono text-muted-foreground uppercase">{label}</span>
|
||||
</div>
|
||||
<div className={`font-mono text-xl font-bold ${color}`}>{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
251
client/src/components/WorkflowNodeBlock.tsx
Normal file
251
client/src/components/WorkflowNodeBlock.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* WorkflowNodeBlock — individual draggable node block inside the canvas.
|
||||
* Rendered as a card with kind-specific icon/color, label, and connection ports.
|
||||
* The runtime status overlay (running/success/failed) is shown during execution.
|
||||
*/
|
||||
import {
|
||||
Bot,
|
||||
Box,
|
||||
Play,
|
||||
GitFork,
|
||||
Flag,
|
||||
GripVertical,
|
||||
Trash2,
|
||||
Settings,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
SkipForward,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export type NodeKind = "agent" | "container" | "trigger" | "condition" | "output";
|
||||
|
||||
export interface WFNodeData {
|
||||
nodeKey: string;
|
||||
label: string;
|
||||
kind: NodeKind;
|
||||
agentId?: number | null;
|
||||
agentName?: string;
|
||||
containerConfig?: Record<string, any>;
|
||||
conditionExpr?: string;
|
||||
triggerConfig?: Record<string, any>;
|
||||
posX: number;
|
||||
posY: number;
|
||||
meta?: Record<string, any>;
|
||||
/** Runtime status — set during execution */
|
||||
runStatus?: "pending" | "running" | "success" | "failed" | "skipped";
|
||||
runDurationMs?: number;
|
||||
runError?: string;
|
||||
}
|
||||
|
||||
const KIND_CONFIG: Record<NodeKind, { icon: any; color: string; bg: string; border: string; label: string }> = {
|
||||
trigger: { icon: Play, color: "text-neon-green", bg: "bg-neon-green/10", border: "border-neon-green/40", label: "Trigger" },
|
||||
agent: { icon: Bot, color: "text-primary", bg: "bg-primary/10", border: "border-primary/40", label: "Agent" },
|
||||
container: { icon: Box, color: "text-neon-amber", bg: "bg-neon-amber/10", border: "border-neon-amber/40", label: "Container" },
|
||||
condition: { icon: GitFork, color: "text-purple-400", bg: "bg-purple-400/10", border: "border-purple-400/40", label: "Condition" },
|
||||
output: { icon: Flag, color: "text-cyan-400", bg: "bg-cyan-400/10", border: "border-cyan-400/40", label: "Output" },
|
||||
};
|
||||
|
||||
const STATUS_OVERLAY: Record<string, { icon: any; color: string; animate?: boolean }> = {
|
||||
running: { icon: Loader2, color: "text-primary", animate: true },
|
||||
success: { icon: CheckCircle, color: "text-neon-green" },
|
||||
failed: { icon: XCircle, color: "text-neon-red" },
|
||||
skipped: { icon: SkipForward, color: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
interface WorkflowNodeBlockProps {
|
||||
node: WFNodeData;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDragStart?: (e: React.MouseEvent) => void;
|
||||
showPorts?: boolean;
|
||||
/** Connection port mouse-down handlers */
|
||||
onPortMouseDown?: (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function WorkflowNodeBlock({
|
||||
node,
|
||||
selected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDragStart,
|
||||
showPorts = true,
|
||||
onPortMouseDown,
|
||||
}: WorkflowNodeBlockProps) {
|
||||
const cfg = KIND_CONFIG[node.kind];
|
||||
const Icon = cfg.icon;
|
||||
const statusOverlay = node.runStatus ? STATUS_OVERLAY[node.runStatus] : null;
|
||||
const StatusIcon = statusOverlay?.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`
|
||||
absolute select-none cursor-grab active:cursor-grabbing
|
||||
w-[220px] rounded-lg border ${cfg.border} ${cfg.bg}
|
||||
${selected ? "ring-2 ring-primary/60 shadow-lg shadow-primary/10" : ""}
|
||||
${node.runStatus === "running" ? "ring-2 ring-primary/40 animate-pulse" : ""}
|
||||
backdrop-blur-sm transition-shadow
|
||||
`}
|
||||
style={{ left: node.posX, top: node.posY }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect?.(); }}
|
||||
onMouseDown={onDragStart}
|
||||
>
|
||||
{/* Input port */}
|
||||
{showPorts && node.kind !== "trigger" && (
|
||||
<div
|
||||
className="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-secondary border-2 border-border hover:border-primary hover:bg-primary/20 cursor-crosshair z-20 transition-colors"
|
||||
onMouseDown={(e) => { e.stopPropagation(); onPortMouseDown?.(node.nodeKey, "input", e); }}
|
||||
title="Input"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/30">
|
||||
<GripVertical className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<div className={`w-7 h-7 rounded-md ${cfg.bg} border ${cfg.border} flex items-center justify-center shrink-0`}>
|
||||
<Icon className={`w-4 h-4 ${cfg.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-semibold text-foreground truncate">{node.label}</div>
|
||||
<div className={`text-[10px] font-mono ${cfg.color}`}>{cfg.label}</div>
|
||||
</div>
|
||||
{/* Status overlay */}
|
||||
{statusOverlay && StatusIcon && (
|
||||
<StatusIcon
|
||||
className={`w-4 h-4 ${statusOverlay.color} shrink-0 ${statusOverlay.animate ? "animate-spin" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{node.kind === "agent" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
{node.agentName ? (
|
||||
<span>Agent: <span className="text-primary">{node.agentName}</span></span>
|
||||
) : node.agentId ? (
|
||||
<span>Agent ID: <span className="text-primary">#{node.agentId}</span></span>
|
||||
) : (
|
||||
<span className="text-neon-amber">No agent assigned</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.kind === "container" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{node.containerConfig?.image ? (
|
||||
<span>Image: <span className="text-neon-amber">{node.containerConfig.image as string}</span></span>
|
||||
) : (
|
||||
<span className="text-neon-amber">No image configured</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.kind === "condition" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{node.conditionExpr ? (
|
||||
<code className="text-purple-400">{node.conditionExpr}</code>
|
||||
) : (
|
||||
<span className="text-purple-400/60">No condition set</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.kind === "trigger" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
{node.triggerConfig?.type === "cron" ? (
|
||||
<span>Cron: <span className="text-neon-green">{node.triggerConfig.cron as string}</span></span>
|
||||
) : node.triggerConfig?.type === "webhook" ? (
|
||||
<span>Webhook: <span className="text-neon-green">{node.triggerConfig.webhookPath as string}</span></span>
|
||||
) : (
|
||||
<span className="text-neon-green">Manual start</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.kind === "output" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
Final output
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Runtime info */}
|
||||
{node.runDurationMs !== undefined && node.runStatus !== "pending" && node.runStatus !== "running" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
Duration: <span className="text-foreground">{node.runDurationMs}ms</span>
|
||||
</div>
|
||||
)}
|
||||
{node.runError && (
|
||||
<div className="text-[10px] font-mono text-neon-red truncate" title={node.runError}>
|
||||
{node.runError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions (visible when selected) */}
|
||||
{selected && (
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-t border-border/30">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit?.(); }}
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-neon-red"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.(); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output port */}
|
||||
{showPorts && node.kind !== "output" && (
|
||||
<div
|
||||
className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-secondary border-2 border-border hover:border-primary hover:bg-primary/20 cursor-crosshair z-20 transition-colors"
|
||||
onMouseDown={(e) => { e.stopPropagation(); onPortMouseDown?.(node.nodeKey, "output", e); }}
|
||||
title="Output"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini node block for the sidebar palette (drag source).
|
||||
*/
|
||||
export function WorkflowNodePaletteItem({
|
||||
kind,
|
||||
onDragStart,
|
||||
}: {
|
||||
kind: NodeKind;
|
||||
onDragStart: (kind: NodeKind) => void;
|
||||
}) {
|
||||
const cfg = KIND_CONFIG[kind];
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-2.5 px-3 py-2 rounded-md border ${cfg.border} ${cfg.bg}
|
||||
cursor-grab active:cursor-grabbing hover:brightness-110 transition-all
|
||||
`}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(kind)}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded flex items-center justify-center ${cfg.bg} border ${cfg.border}`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${cfg.color}`} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">{cfg.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
client/src/components/WorkflowNodeEditModal.tsx
Normal file
253
client/src/components/WorkflowNodeEditModal.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* WorkflowNodeEditModal — configure individual node properties.
|
||||
* Agent nodes get a selector for existing agents.
|
||||
* Container nodes get image/env/ports fields.
|
||||
* Condition nodes get an expression editor.
|
||||
* Trigger nodes get type/cron/webhook fields.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bot, Box, Play, GitFork, Flag, Save } from "lucide-react";
|
||||
import type { WFNodeData, NodeKind } from "./WorkflowNodeBlock";
|
||||
|
||||
interface WorkflowNodeEditModalProps {
|
||||
node: WFNodeData;
|
||||
agents: any[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (node: WFNodeData) => void;
|
||||
}
|
||||
|
||||
export function WorkflowNodeEditModal({
|
||||
node,
|
||||
agents,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: WorkflowNodeEditModalProps) {
|
||||
const [label, setLabel] = useState(node.label);
|
||||
const [agentId, setAgentId] = useState<string>(node.agentId ? String(node.agentId) : "");
|
||||
const [dockerImage, setDockerImage] = useState((node.containerConfig?.image as string) ?? "");
|
||||
const [dockerEnv, setDockerEnv] = useState((node.containerConfig?.env as string[] ?? []).join("\n"));
|
||||
const [dockerCommand, setDockerCommand] = useState((node.containerConfig?.command as string) ?? "");
|
||||
const [conditionExpr, setConditionExpr] = useState(node.conditionExpr ?? "");
|
||||
const [triggerType, setTriggerType] = useState((node.triggerConfig?.type as string) ?? "manual");
|
||||
const [cronExpr, setCronExpr] = useState((node.triggerConfig?.cron as string) ?? "");
|
||||
const [webhookPath, setWebhookPath] = useState((node.triggerConfig?.webhookPath as string) ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setLabel(node.label);
|
||||
setAgentId(node.agentId ? String(node.agentId) : "");
|
||||
setDockerImage((node.containerConfig?.image as string) ?? "");
|
||||
setDockerEnv((node.containerConfig?.env as string[] ?? []).join("\n"));
|
||||
setDockerCommand((node.containerConfig?.command as string) ?? "");
|
||||
setConditionExpr(node.conditionExpr ?? "");
|
||||
setTriggerType((node.triggerConfig?.type as string) ?? "manual");
|
||||
setCronExpr((node.triggerConfig?.cron as string) ?? "");
|
||||
setWebhookPath((node.triggerConfig?.webhookPath as string) ?? "");
|
||||
}, [node]);
|
||||
|
||||
const handleSave = () => {
|
||||
const selectedAgent = agents.find((a: any) => a.id === Number(agentId));
|
||||
const updated: WFNodeData = {
|
||||
...node,
|
||||
label,
|
||||
agentId: agentId ? Number(agentId) : undefined,
|
||||
agentName: selectedAgent?.name,
|
||||
containerConfig: {
|
||||
image: dockerImage,
|
||||
env: dockerEnv.split("\n").filter(Boolean),
|
||||
command: dockerCommand,
|
||||
},
|
||||
conditionExpr,
|
||||
triggerConfig: {
|
||||
type: triggerType,
|
||||
cron: cronExpr,
|
||||
webhookPath,
|
||||
},
|
||||
};
|
||||
onSave(updated);
|
||||
};
|
||||
|
||||
const kindIcons: Record<NodeKind, any> = {
|
||||
trigger: Play,
|
||||
agent: Bot,
|
||||
container: Box,
|
||||
condition: GitFork,
|
||||
output: Flag,
|
||||
};
|
||||
const KindIcon = kindIcons[node.kind];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<KindIcon className="w-5 h-5 text-primary" />
|
||||
Edit {node.kind.charAt(0).toUpperCase() + node.kind.slice(1)} Node
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Label */}
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Label</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Node name"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent config */}
|
||||
{node.kind === "agent" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Agent</Label>
|
||||
<Select value={agentId} onValueChange={setAgentId}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent: any) => (
|
||||
<SelectItem key={agent.id} value={String(agent.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{agent.name}</span>
|
||||
<Badge variant="outline" className="text-[9px] font-mono">{agent.role}</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{agentId && (() => {
|
||||
const a = agents.find((ag: any) => ag.id === Number(agentId));
|
||||
if (!a) return null;
|
||||
return (
|
||||
<div className="mt-2 p-2 rounded bg-secondary/30 border border-border/30 text-[10px] font-mono space-y-1">
|
||||
<div>Model: <span className="text-primary">{a.model}</span></div>
|
||||
<div>Provider: <span className="text-muted-foreground">{a.provider}</span></div>
|
||||
{a.description && <div className="text-muted-foreground">{a.description}</div>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Container config */}
|
||||
{node.kind === "container" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Docker Image</Label>
|
||||
<Input
|
||||
value={dockerImage}
|
||||
onChange={(e) => setDockerImage(e.target.value)}
|
||||
placeholder="e.g. python:3.12-slim"
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Command</Label>
|
||||
<Input
|
||||
value={dockerCommand}
|
||||
onChange={(e) => setDockerCommand(e.target.value)}
|
||||
placeholder="e.g. python /app/main.py"
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Environment Variables (one per line)</Label>
|
||||
<Textarea
|
||||
value={dockerEnv}
|
||||
onChange={(e) => setDockerEnv(e.target.value)}
|
||||
placeholder="KEY=VALUE"
|
||||
className="mt-1 font-mono text-xs min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Condition config */}
|
||||
{node.kind === "condition" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Condition Expression</Label>
|
||||
<Textarea
|
||||
value={conditionExpr}
|
||||
onChange={(e) => setConditionExpr(e.target.value)}
|
||||
placeholder="e.g. input.length > 0"
|
||||
className="mt-1 font-mono text-xs min-h-[60px]"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Evaluates to true/false. If false, downstream nodes are skipped.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trigger config */}
|
||||
{node.kind === "trigger" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Trigger Type</Label>
|
||||
<Select value={triggerType} onValueChange={setTriggerType}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="cron">Cron Schedule</SelectItem>
|
||||
<SelectItem value="webhook">Webhook</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{triggerType === "cron" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Cron Expression</Label>
|
||||
<Input
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
placeholder="*/5 * * * *"
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{triggerType === "webhook" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Webhook Path</Label>
|
||||
<Input
|
||||
value={webhookPath}
|
||||
onChange={(e) => setWebhookPath(e.target.value)}
|
||||
placeholder="/webhook/my-trigger"
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border/30">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25">
|
||||
<Save className="w-3.5 h-3.5 mr-1" /> Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
const joinMut = trpc.nodes.joinNode.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setJoinResult(data as JoinResult);
|
||||
// Trigger parent refresh but DO NOT close dialog — let user read the result
|
||||
if ((data as JoinResult).ok) onSuccess();
|
||||
},
|
||||
onError: (e) => setJoinResult({ ok: false, error: e.message, step: "trpc" }),
|
||||
@@ -60,6 +61,8 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
const busy = testMut.isPending || joinMut.isPending;
|
||||
const canAct = !!host.trim() && !!user.trim() && !!password && !busy;
|
||||
const joinDone = joinResult?.ok === true;
|
||||
// After a successful join, allow re-testing but not re-joining
|
||||
const canJoin = canAct && !joinDone;
|
||||
|
||||
const handleTest = () => {
|
||||
setTestResult(null);
|
||||
@@ -73,6 +76,7 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
};
|
||||
|
||||
const inputClass = "h-8 text-xs font-mono";
|
||||
// Disable input fields while busy or after successful join
|
||||
const disabled = busy || joinDone;
|
||||
|
||||
return (
|
||||
@@ -262,7 +266,7 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
{/* Join swarm */}
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
disabled={!canAct}
|
||||
disabled={!canJoin}
|
||||
className="flex-1 h-8 text-xs bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-40"
|
||||
>
|
||||
{joinMut.isPending
|
||||
@@ -1204,7 +1208,7 @@ export default function Nodes() {
|
||||
{showAddNode && (
|
||||
<AddNodeDialog
|
||||
onClose={() => setShowAddNode(false)}
|
||||
onSuccess={() => { setShowAddNode(false); nodesQ.refetch(); swarmInfoQ.refetch(); }}
|
||||
onSuccess={() => { nodesQ.refetch(); swarmInfoQ.refetch(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
410
client/src/pages/Workflows.tsx
Normal file
410
client/src/pages/Workflows.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Workflows — Main page: list view, canvas constructor, and dashboard.
|
||||
*
|
||||
* Views:
|
||||
* 1. List view — all workflows with status, stats, quick actions
|
||||
* 2. Canvas view — visual drag-and-drop constructor (full screen)
|
||||
* 3. Dashboard view — run monitoring for a selected workflow
|
||||
*
|
||||
* Design: Mission Control theme — dark bg, cyan glow, mono fonts.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRoute, useLocation } from "wouter";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
GitBranch,
|
||||
Plus,
|
||||
Play,
|
||||
Pause,
|
||||
Trash2,
|
||||
Settings,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Activity,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
Pencil,
|
||||
Archive,
|
||||
Zap,
|
||||
BarChart2,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { WorkflowCreateModal } from "@/components/WorkflowCreateModal";
|
||||
import WorkflowCanvas from "@/components/WorkflowCanvas";
|
||||
import WorkflowDashboard from "@/components/WorkflowDashboard";
|
||||
import type { WFNodeData } from "@/components/WorkflowNodeBlock";
|
||||
import type { WFEdgeData } from "@/components/WorkflowCanvas";
|
||||
|
||||
const STATUS_STYLE: Record<string, { badge: string; dot: string }> = {
|
||||
draft: { badge: "bg-muted/15 text-muted-foreground border-border", dot: "bg-muted-foreground" },
|
||||
active: { badge: "bg-neon-green/15 text-neon-green border-neon-green/30", dot: "bg-neon-green pulse-indicator" },
|
||||
paused: { badge: "bg-neon-amber/15 text-neon-amber border-neon-amber/30", dot: "bg-neon-amber" },
|
||||
archived: { badge: "bg-muted/15 text-muted-foreground border-border", dot: "bg-muted-foreground" },
|
||||
};
|
||||
|
||||
type ViewMode = "list" | "canvas" | "dashboard";
|
||||
|
||||
export default function Workflows() {
|
||||
const [, params] = useRoute("/workflows/:id");
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<number | null>(
|
||||
params?.id ? Number(params.id) : null
|
||||
);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [workflowToDelete, setWorkflowToDelete] = useState<number | null>(null);
|
||||
|
||||
// If URL has /workflows/:id, load that workflow
|
||||
useEffect(() => {
|
||||
if (params?.id) {
|
||||
setSelectedWorkflowId(Number(params.id));
|
||||
setViewMode("dashboard");
|
||||
}
|
||||
}, [params?.id]);
|
||||
|
||||
// List all workflows
|
||||
const { data: workflows = [], isLoading, refetch } = trpc.workflows.list.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Get single workflow (for canvas view)
|
||||
const { data: selectedWorkflow } = trpc.workflows.get.useQuery(
|
||||
{ id: selectedWorkflowId! },
|
||||
{ enabled: !!selectedWorkflowId && (viewMode === "canvas" || viewMode === "dashboard") }
|
||||
);
|
||||
|
||||
// Get latest run for polling node statuses
|
||||
const { data: latestRuns } = trpc.workflows.listRuns.useQuery(
|
||||
{ workflowId: selectedWorkflowId!, limit: 1 },
|
||||
{ enabled: !!selectedWorkflowId && viewMode === "canvas", refetchInterval: 3_000 }
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const deleteMutation = trpc.workflows.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Workflow deleted");
|
||||
setDeleteConfirmOpen(false);
|
||||
setWorkflowToDelete(null);
|
||||
refetch();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.workflows.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Workflow updated");
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenCanvas = (id: number) => {
|
||||
setSelectedWorkflowId(id);
|
||||
setViewMode("canvas");
|
||||
};
|
||||
|
||||
const handleOpenDashboard = (id: number) => {
|
||||
setSelectedWorkflowId(id);
|
||||
setViewMode("dashboard");
|
||||
navigate(`/workflows/${id}`);
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
setViewMode("list");
|
||||
setSelectedWorkflowId(null);
|
||||
navigate("/workflows");
|
||||
};
|
||||
|
||||
const handleToggleStatus = (id: number, currentStatus: string) => {
|
||||
const newStatus = currentStatus === "active" ? "paused" : "active";
|
||||
updateMutation.mutate({ id, status: newStatus as any });
|
||||
};
|
||||
|
||||
// Build canvas data from server response
|
||||
const canvasNodes: WFNodeData[] = (selectedWorkflow?.nodes ?? []).map((n: any) => ({
|
||||
nodeKey: n.nodeKey,
|
||||
label: n.label,
|
||||
kind: n.kind,
|
||||
agentId: n.agentId,
|
||||
containerConfig: n.containerConfig,
|
||||
conditionExpr: n.conditionExpr,
|
||||
triggerConfig: n.triggerConfig,
|
||||
posX: n.posX ?? 0,
|
||||
posY: n.posY ?? 0,
|
||||
meta: n.meta,
|
||||
}));
|
||||
|
||||
const canvasEdges: WFEdgeData[] = (selectedWorkflow?.edges ?? []).map((e: any) => ({
|
||||
edgeKey: e.edgeKey,
|
||||
sourceNodeKey: e.sourceNodeKey,
|
||||
targetNodeKey: e.targetNodeKey,
|
||||
sourceHandle: e.sourceHandle,
|
||||
targetHandle: e.targetHandle,
|
||||
label: e.label,
|
||||
meta: e.meta,
|
||||
}));
|
||||
|
||||
// Run results for canvas overlay
|
||||
const latestRun = latestRuns?.[0];
|
||||
const runResults = latestRun?.status === "running" || latestRun?.status === "success" || latestRun?.status === "failed"
|
||||
? (latestRun.nodeResults as any) ?? {}
|
||||
: undefined;
|
||||
|
||||
// ─── Canvas View ──────────────────────────────────────────────────────────
|
||||
if (viewMode === "canvas" && selectedWorkflowId && selectedWorkflow) {
|
||||
return (
|
||||
<div className="-m-6 h-[calc(100vh-3.5rem)]">
|
||||
<WorkflowCanvas
|
||||
workflowId={selectedWorkflowId}
|
||||
workflowName={selectedWorkflow.name}
|
||||
initialNodes={canvasNodes}
|
||||
initialEdges={canvasEdges}
|
||||
runResults={runResults}
|
||||
onBack={handleBackToList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dashboard View ───────────────────────────────────────────────────────
|
||||
if (viewMode === "dashboard" && selectedWorkflowId && selectedWorkflow) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button size="sm" variant="ghost" onClick={handleBackToList} className="text-muted-foreground hover:text-foreground mb-2">
|
||||
← Back to Workflows
|
||||
</Button>
|
||||
|
||||
<Tabs defaultValue="dashboard">
|
||||
<TabsList className="bg-secondary/30 border border-border/30">
|
||||
<TabsTrigger value="dashboard" className="data-[state=active]:bg-primary/15 data-[state=active]:text-primary">
|
||||
<BarChart2 className="w-3.5 h-3.5 mr-1.5" /> Dashboard
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="canvas" className="data-[state=active]:bg-primary/15 data-[state=active]:text-primary">
|
||||
<GitBranch className="w-3.5 h-3.5 mr-1.5" /> Canvas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="dashboard" className="mt-4">
|
||||
<WorkflowDashboard
|
||||
workflowId={selectedWorkflowId}
|
||||
workflowName={selectedWorkflow.name}
|
||||
onOpenCanvas={() => setViewMode("canvas")}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="canvas" className="mt-4">
|
||||
<div className="-mx-6 -mb-6 h-[calc(100vh-14rem)]">
|
||||
<WorkflowCanvas
|
||||
workflowId={selectedWorkflowId}
|
||||
workflowName={selectedWorkflow.name}
|
||||
initialNodes={canvasNodes}
|
||||
initialEdges={canvasEdges}
|
||||
runResults={runResults}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── List View ────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground">Workflows</h2>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{workflows.length} workflows · Visual pipeline constructor
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Workflow
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : workflows.length === 0 ? (
|
||||
/* Empty state */
|
||||
<Card className="bg-card border-border/50">
|
||||
<CardContent className="p-12 text-center">
|
||||
<GitBranch className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-30" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No Workflows Yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Create your first workflow to build visual agent pipelines.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create First Workflow
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* Workflow grid */
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{workflows.map((wf: any, i: number) => {
|
||||
const ss = STATUS_STYLE[wf.status] ?? STATUS_STYLE.draft;
|
||||
return (
|
||||
<motion.div
|
||||
key={wf.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<Card className="bg-card border-border/50 hover:border-primary/30 transition-all cursor-pointer group"
|
||||
onClick={() => handleOpenDashboard(wf.id)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
{/* Top row */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/30 flex items-center justify-center">
|
||||
<GitBranch className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{wf.name}
|
||||
</h3>
|
||||
{wf.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate max-w-[180px]">{wf.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] font-mono ${ss.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ss.dot} mr-1.5`} />
|
||||
{wf.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{wf.tags && wf.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{(wf.tags as string[]).map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-[9px] font-mono bg-secondary/30 text-muted-foreground border-border/30 px-1.5 py-0">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex items-center gap-4 mb-3 text-[10px] font-mono text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Created: {new Date(wf.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
Updated: {new Date(wf.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-border/30">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
|
||||
onClick={(e) => { e.stopPropagation(); handleOpenCanvas(wf.id); }}
|
||||
>
|
||||
<Pencil className="w-3 h-3 mr-1" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
|
||||
onClick={(e) => { e.stopPropagation(); handleOpenDashboard(wf.id); }}
|
||||
>
|
||||
<BarChart2 className="w-3 h-3 mr-1" /> Monitor
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`h-7 text-[11px] ml-auto ${
|
||||
wf.status === "active"
|
||||
? "text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
|
||||
: "text-neon-green border-neon-green/30 hover:bg-neon-green/10"
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleStatus(wf.id, wf.status); }}
|
||||
>
|
||||
{wf.status === "active" ? <Pause className="w-3 h-3 mr-1" /> : <Play className="w-3 h-3 mr-1" />}
|
||||
{wf.status === "active" ? "Pause" : "Activate"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10"
|
||||
onClick={(e) => { e.stopPropagation(); setWorkflowToDelete(wf.id); setDeleteConfirmOpen(true); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create modal */}
|
||||
<WorkflowCreateModal
|
||||
open={createModalOpen}
|
||||
onOpenChange={setCreateModalOpen}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the workflow, all nodes, edges, and run history. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => workflowToDelete && deleteMutation.mutate({ id: workflowToDelete })}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-neon-red hover:bg-neon-red/90"
|
||||
>
|
||||
{deleteMutation.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user