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:
bboxwtf
2026-03-22 00:10:53 +00:00
parent 3be643992d
commit 5ff2ade579
14 changed files with 2635 additions and 4 deletions

View File

@@ -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} />

View File

@@ -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: "Ноды" },

View 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 &middot; {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>
);
}

View 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>
);
}

View 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 &middot; 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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(); }}
/>
)}

View 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">
&larr; 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 &middot; 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>
);
}

View File

@@ -0,0 +1,77 @@
-- Workflows: pipeline definitions
CREATE TABLE `workflows` (
`id` int AUTO_INCREMENT NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`status` enum('draft','active','paused','archived') NOT NULL DEFAULT 'draft',
`canvasMeta` json DEFAULT ('{}'),
`tags` json DEFAULT ('[]'),
`createdBy` int,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `workflows_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
-- Workflow Nodes: blocks inside a workflow (agent / container / trigger / condition / output)
CREATE TABLE `workflowNodes` (
`id` int AUTO_INCREMENT NOT NULL,
`workflowId` int NOT NULL,
`nodeKey` varchar(64) NOT NULL,
`label` varchar(255) NOT NULL,
`kind` enum('agent','container','trigger','condition','output') NOT NULL,
`agentId` int,
`containerConfig` json DEFAULT ('{}'),
`conditionExpr` text,
`triggerConfig` json DEFAULT ('{}'),
`posX` int DEFAULT 0,
`posY` int DEFAULT 0,
`meta` json DEFAULT ('{}'),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `workflowNodes_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `workflowNodes_workflowId_idx` ON `workflowNodes` (`workflowId`);
--> statement-breakpoint
-- Workflow Edges: connections between nodes
CREATE TABLE `workflowEdges` (
`id` int AUTO_INCREMENT NOT NULL,
`workflowId` int NOT NULL,
`edgeKey` varchar(64) NOT NULL,
`sourceNodeKey` varchar(64) NOT NULL,
`targetNodeKey` varchar(64) NOT NULL,
`sourceHandle` varchar(64),
`targetHandle` varchar(64),
`label` varchar(128),
`meta` json DEFAULT ('{}'),
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `workflowEdges_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `workflowEdges_workflowId_idx` ON `workflowEdges` (`workflowId`);
--> statement-breakpoint
-- Workflow Runs: execution history with per-node results
CREATE TABLE `workflowRuns` (
`id` int AUTO_INCREMENT NOT NULL,
`workflowId` int NOT NULL,
`runKey` varchar(64) NOT NULL,
`status` enum('pending','running','success','failed','cancelled') NOT NULL DEFAULT 'pending',
`nodeResults` json DEFAULT ('{}'),
`currentNodeKey` varchar(64),
`input` text,
`output` text,
`totalDurationMs` int,
`errorMessage` text,
`startedAt` timestamp,
`finishedAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `workflowRuns_id` PRIMARY KEY(`id`),
CONSTRAINT `workflowRuns_runKey_unique` UNIQUE(`runKey`)
);
--> statement-breakpoint
CREATE INDEX `workflowRuns_workflowId_idx` ON `workflowRuns` (`workflowId`);
--> statement-breakpoint
CREATE INDEX `workflowRuns_status_idx` ON `workflowRuns` (`status`);

View File

@@ -274,3 +274,128 @@ export const chatEvents = mysqlTable("chatEvents", {
export type ChatEvent = typeof chatEvents.$inferSelect;
export type InsertChatEvent = typeof chatEvents.$inferInsert;
// ─── Workflows ────────────────────────────────────────────────────────────────
/**
* Workflows — visual pipeline definitions composed of agent/container nodes.
* Each workflow is a directed graph stored as nodes + edges.
*/
export const workflows = mysqlTable("workflows", {
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
/** Visual status used in the list/dashboard */
status: mysqlEnum("status", ["draft", "active", "paused", "archived"]).default("draft").notNull(),
/** JSON blob of canvas-level metadata: viewport position, zoom, layout hints */
canvasMeta: json("canvasMeta").$type<{ viewportX?: number; viewportY?: number; zoom?: number }>().default({}),
tags: json("tags").$type<string[]>().default([]),
createdBy: int("createdBy"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Workflow = typeof workflows.$inferSelect;
export type InsertWorkflow = typeof workflows.$inferInsert;
/**
* Workflow Nodes — individual blocks inside a workflow.
* Each node references either an agent (agentId) or an arbitrary container config.
*/
export const workflowNodes = mysqlTable("workflowNodes", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
/** Unique client-side ID used by the canvas (e.g. "node_abc123") */
nodeKey: varchar("nodeKey", { length: 64 }).notNull(),
label: varchar("label", { length: 255 }).notNull(),
/** Node kind: agent = uses an existing agent; container = custom Docker image; trigger = entry point; output = terminal */
kind: mysqlEnum("kind", ["agent", "container", "trigger", "condition", "output"]).notNull(),
/** Link to agents table (nullable — only for kind=agent) */
agentId: int("agentId"),
/** For kind=container: Docker image, env vars, ports etc. */
containerConfig: json("containerConfig").$type<{
image?: string;
env?: string[];
ports?: string[];
command?: string;
volumes?: string[];
}>().default({}),
/** For kind=condition: JS expression evaluated at runtime */
conditionExpr: text("conditionExpr"),
/** Trigger config: cron, webhook, manual */
triggerConfig: json("triggerConfig").$type<{ type?: string; cron?: string; webhookPath?: string }>().default({}),
/** Canvas position */
posX: int("posX").default(0),
posY: int("posY").default(0),
/** Extra metadata (colour, icon override, etc.) */
meta: json("meta").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowNodes_workflowId_idx").on(table.workflowId),
}));
export type WorkflowNode = typeof workflowNodes.$inferSelect;
export type InsertWorkflowNode = typeof workflowNodes.$inferInsert;
/**
* Workflow Edges — connections between nodes.
*/
export const workflowEdges = mysqlTable("workflowEdges", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
/** Edge identifier on the canvas */
edgeKey: varchar("edgeKey", { length: 64 }).notNull(),
sourceNodeKey: varchar("sourceNodeKey", { length: 64 }).notNull(),
targetNodeKey: varchar("targetNodeKey", { length: 64 }).notNull(),
/** Optional: which output handle → which input handle */
sourceHandle: varchar("sourceHandle", { length: 64 }),
targetHandle: varchar("targetHandle", { length: 64 }),
/** Edge label (e.g. "on success", "on fail") */
label: varchar("label", { length: 128 }),
/** Visual styling */
meta: json("meta").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowEdges_workflowId_idx").on(table.workflowId),
}));
export type WorkflowEdge = typeof workflowEdges.$inferSelect;
export type InsertWorkflowEdge = typeof workflowEdges.$inferInsert;
/**
* Workflow Runs — execution history. Each run tracks overall status and
* per-node results so the dashboard can show progress in real-time.
*/
export const workflowRuns = mysqlTable("workflowRuns", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
runKey: varchar("runKey", { length: 64 }).notNull().unique(),
status: mysqlEnum("status", ["pending", "running", "success", "failed", "cancelled"]).default("pending").notNull(),
/** Per-node execution results: { [nodeKey]: { status, output, durationMs, error? } } */
nodeResults: json("nodeResults").$type<Record<string, {
status: "pending" | "running" | "success" | "failed" | "skipped";
output?: string;
durationMs?: number;
error?: string;
startedAt?: string;
finishedAt?: string;
}>>().default({}),
/** The node currently being executed */
currentNodeKey: varchar("currentNodeKey", { length: 64 }),
/** Global input passed to the first node */
input: text("input"),
/** Final aggregated output */
output: text("output"),
totalDurationMs: int("totalDurationMs"),
errorMessage: text("errorMessage"),
startedAt: timestamp("startedAt"),
finishedAt: timestamp("finishedAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowRuns_workflowId_idx").on(table.workflowId),
statusIdx: index("workflowRuns_status_idx").on(table.status),
}));
export type WorkflowRun = typeof workflowRuns.$inferSelect;
export type InsertWorkflowRun = typeof workflowRuns.$inferInsert;

View File

@@ -1510,6 +1510,21 @@ func (h *Handler) SwarmJoinNodeViaSSH(w http.ResponseWriter, r *http.Request) {
}
log.Printf("[SwarmJoinNode] Success: %s joined as %s", body.Host, body.Role)
// Give Docker Swarm ~3 seconds to propagate the new node, then sync to DB.
go func() {
time.Sleep(3 * time.Second)
nodes, err := h.docker.ListNodes()
if err != nil {
log.Printf("[SwarmJoinNode] DB sync failed (ListNodes): %v", err)
return
}
if h.db != nil {
h.db.UpsertSwarmNodes(nodes)
log.Printf("[SwarmJoinNode] DB synced: %d nodes after join", len(nodes))
}
}()
respond(w, http.StatusOK, map[string]any{
"ok": true,
"output": output,
@@ -1575,9 +1590,13 @@ func (h *Handler) SwarmSSHTest(w http.ResponseWriter, r *http.Request) {
}
defer sess.Close()
out, _ := sess.CombinedOutput("docker version --format '{{.Server.Version}}' 2>/dev/null || echo 'docker_not_found'")
// Use plain 'docker info' to get server version — works on all distros
out, _ := sess.CombinedOutput("docker info --format '{{.ServerVersion}}' 2>/dev/null || docker version --format '{{.Server.Version}}' 2>/dev/null || echo 'docker_not_found'")
dockerVer := strings.TrimSpace(string(out))
dockerOk := dockerVer != "" && dockerVer != "docker_not_found"
if dockerVer == "" {
dockerVer = "docker_not_found"
}
dockerOk := dockerVer != "docker_not_found" && !strings.Contains(dockerVer, "not found") && !strings.Contains(dockerVer, "command not found")
log.Printf("[SSHTest] %s — SSH OK, docker: %s", addr, dockerVer)
respond(w, http.StatusOK, map[string]any{

View File

@@ -1100,5 +1100,143 @@ export const appRouter = router({
return result;
}),
}),
/**
* Workflows — visual pipeline builder (CRUD + execution)
*/
workflows: router({
/** List all workflows */
list: publicProcedure.query(async () => {
const { getAllWorkflows } = await import("./workflows");
return getAllWorkflows();
}),
/** Get a single workflow with its nodes and edges */
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const { getWorkflowById } = await import("./workflows");
return getWorkflowById(input.id);
}),
/** Create a new workflow */
create: publicProcedure
.input(z.object({
name: z.string().min(1),
description: z.string().optional(),
tags: z.array(z.string()).default([]),
}))
.mutation(async ({ input }) => {
const { createWorkflow } = await import("./workflows");
return createWorkflow({ ...input, status: "draft" });
}),
/** Update workflow metadata */
update: publicProcedure
.input(z.object({
id: z.number(),
name: z.string().optional(),
description: z.string().optional(),
status: z.enum(["draft", "active", "paused", "archived"]).optional(),
tags: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const { updateWorkflow } = await import("./workflows");
const { id, ...data } = input;
return updateWorkflow(id, data as any);
}),
/** Delete a workflow and all its nodes/edges/runs */
delete: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { deleteWorkflow } = await import("./workflows");
return deleteWorkflow(input.id);
}),
/** Save the full canvas (nodes + edges) atomically */
saveCanvas: publicProcedure
.input(z.object({
workflowId: z.number(),
nodes: z.array(z.object({
nodeKey: z.string(),
label: z.string(),
kind: z.enum(["agent", "container", "trigger", "condition", "output"]),
agentId: z.number().nullable().optional(),
containerConfig: z.record(z.string(), z.unknown()).optional(),
conditionExpr: z.string().optional(),
triggerConfig: z.record(z.string(), z.unknown()).optional(),
posX: z.number().default(0),
posY: z.number().default(0),
meta: z.record(z.string(), z.unknown()).optional(),
})),
edges: z.array(z.object({
edgeKey: z.string(),
sourceNodeKey: z.string(),
targetNodeKey: z.string(),
sourceHandle: z.string().optional(),
targetHandle: z.string().optional(),
label: z.string().optional(),
meta: z.record(z.string(), z.unknown()).optional(),
})),
canvasMeta: z.record(z.string(), z.unknown()).optional(),
}))
.mutation(async ({ input }) => {
const { saveCanvas } = await import("./workflows");
return saveCanvas(
input.workflowId,
input.nodes.map((n) => ({ ...n, workflowId: input.workflowId } as any)),
input.edges.map((e) => ({ ...e, workflowId: input.workflowId } as any)),
input.canvasMeta,
);
}),
/** Execute a full workflow */
execute: publicProcedure
.input(z.object({ workflowId: z.number(), input: z.string().optional() }))
.mutation(async ({ input }) => {
const { executeWorkflow } = await import("./workflows");
return executeWorkflow(input.workflowId, input.input);
}),
/** Execute a single node (for testing) */
executeNode: publicProcedure
.input(z.object({ workflowId: z.number(), nodeKey: z.string(), input: z.string() }))
.mutation(async ({ input }) => {
const { executeSingleNode } = await import("./workflows");
return executeSingleNode(input.workflowId, input.nodeKey, input.input);
}),
/** Cancel a running workflow */
cancelRun: publicProcedure
.input(z.object({ runKey: z.string() }))
.mutation(async ({ input }) => {
const { cancelRun } = await import("./workflows");
return cancelRun(input.runKey);
}),
/** Get run details */
getRun: publicProcedure
.input(z.object({ runKey: z.string() }))
.query(async ({ input }) => {
const { getRunByKey } = await import("./workflows");
return getRunByKey(input.runKey);
}),
/** List runs for a workflow */
listRuns: publicProcedure
.input(z.object({ workflowId: z.number(), limit: z.number().default(50) }))
.query(async ({ input }) => {
const { getRunsByWorkflow } = await import("./workflows");
return getRunsByWorkflow(input.workflowId, input.limit);
}),
/** Get workflow stats */
stats: publicProcedure
.input(z.object({ workflowId: z.number() }))
.query(async ({ input }) => {
const { getWorkflowStats } = await import("./workflows");
return getWorkflowStats(input.workflowId);
}),
}),
});
export type AppRouter = typeof appRouter;

418
server/workflows.ts Normal file
View File

@@ -0,0 +1,418 @@
/**
* server/workflows.ts — Workflow CRUD, graph operations & execution engine.
*
* A Workflow is a directed graph of nodes (agents / containers / triggers / conditions / outputs)
* connected by edges. The execution engine walks the graph from trigger nodes,
* executing each agent/container block and forwarding the output downstream.
*/
import { eq, desc, and, inArray } from "drizzle-orm";
import {
workflows, workflowNodes, workflowEdges, workflowRuns,
type Workflow, type InsertWorkflow,
type WorkflowNode, type InsertWorkflowNode,
type WorkflowEdge, type InsertWorkflowEdge,
type WorkflowRun,
} from "../drizzle/schema";
import { getDb } from "./db";
import { nanoid } from "nanoid";
// ─── Workflow CRUD ────────────────────────────────────────────────────────────
export async function createWorkflow(data: Omit<InsertWorkflow, "id">): Promise<Workflow | null> {
const db = await getDb();
if (!db) return null;
const result = await db.insert(workflows).values(data);
const id = result[0].insertId;
const [row] = await db.select().from(workflows).where(eq(workflows.id, Number(id))).limit(1);
return row ?? null;
}
export async function getAllWorkflows(): Promise<Workflow[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(workflows).orderBy(desc(workflows.updatedAt));
}
export async function getWorkflowById(id: number) {
const db = await getDb();
if (!db) return null;
const [wf] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
if (!wf) return null;
const nodes = await db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, id));
const edges = await db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, id));
return { ...wf, nodes, edges };
}
export async function updateWorkflow(id: number, data: Partial<InsertWorkflow>): Promise<Workflow | null> {
const db = await getDb();
if (!db) return null;
await db.update(workflows).set(data).where(eq(workflows.id, id));
const [row] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
return row ?? null;
}
export async function deleteWorkflow(id: number): Promise<boolean> {
const db = await getDb();
if (!db) return false;
await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, id));
await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, id));
await db.delete(workflowRuns).where(eq(workflowRuns.workflowId, id));
await db.delete(workflows).where(eq(workflows.id, id));
return true;
}
// ─── Nodes CRUD ───────────────────────────────────────────────────────────────
export async function saveNodes(workflowId: number, nodes: InsertWorkflowNode[]): Promise<WorkflowNode[]> {
const db = await getDb();
if (!db) return [];
// Delete existing nodes for this workflow, then insert fresh set (canvas save = full replace)
await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
if (nodes.length === 0) return [];
await db.insert(workflowNodes).values(
nodes.map((n) => ({
...n,
workflowId,
nodeKey: n.nodeKey || `node_${nanoid(8)}`,
}))
);
return db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
}
// ─── Edges CRUD ───────────────────────────────────────────────────────────────
export async function saveEdges(workflowId: number, edges: InsertWorkflowEdge[]): Promise<WorkflowEdge[]> {
const db = await getDb();
if (!db) return [];
await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
if (edges.length === 0) return [];
await db.insert(workflowEdges).values(
edges.map((e) => ({
...e,
workflowId,
edgeKey: e.edgeKey || `edge_${nanoid(8)}`,
}))
);
return db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
}
// ─── Full canvas save (nodes + edges atomically) ─────────────────────────────
export async function saveCanvas(
workflowId: number,
nodesData: InsertWorkflowNode[],
edgesData: InsertWorkflowEdge[],
canvasMeta?: Record<string, any>,
) {
const db = await getDb();
if (!db) return null;
// Update canvas meta on the workflow itself
if (canvasMeta) {
await db.update(workflows).set({ canvasMeta } as any).where(eq(workflows.id, workflowId));
}
const nodes = await saveNodes(workflowId, nodesData);
const edges = await saveEdges(workflowId, edgesData);
return { nodes, edges };
}
// ─── Workflow Runs ────────────────────────────────────────────────────────────
export async function createRun(workflowId: number, input?: string): Promise<WorkflowRun | null> {
const db = await getDb();
if (!db) return null;
const runKey = `run_${nanoid(12)}`;
await db.insert(workflowRuns).values({
workflowId,
runKey,
status: "pending",
input: input ?? null,
nodeResults: {},
});
const [row] = await db.select().from(workflowRuns).where(eq(workflowRuns.runKey, runKey)).limit(1);
return row ?? null;
}
export async function getRunsByWorkflow(workflowId: number, limit = 50): Promise<WorkflowRun[]> {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(workflowRuns)
.where(eq(workflowRuns.workflowId, workflowId))
.orderBy(desc(workflowRuns.createdAt))
.limit(limit);
}
export async function getRunByKey(runKey: string): Promise<WorkflowRun | null> {
const db = await getDb();
if (!db) return null;
const [row] = await db.select().from(workflowRuns).where(eq(workflowRuns.runKey, runKey)).limit(1);
return row ?? null;
}
export async function updateRun(runKey: string, data: Partial<WorkflowRun>) {
const db = await getDb();
if (!db) return;
await db.update(workflowRuns).set(data as any).where(eq(workflowRuns.runKey, runKey));
}
// ─── Execution Engine ─────────────────────────────────────────────────────────
/**
* Execute a single node. For agent nodes it calls the agent chat mutation;
* for container nodes it can later call Docker SDK; for conditions it evals the expression.
*/
async function executeNode(
node: WorkflowNode,
input: string,
runKey: string,
): Promise<{ output: string; success: boolean; error?: string }> {
const start = Date.now();
try {
switch (node.kind) {
case "agent": {
if (!node.agentId) return { output: "", success: false, error: "No agentId configured" };
const { getAgentById } = await import("./agents");
const agent = await getAgentById(node.agentId);
if (!agent) return { output: "", success: false, error: `Agent #${node.agentId} not found` };
const { chatCompletion } = await import("./ollama");
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
if (agent.systemPrompt) messages.push({ role: "system", content: agent.systemPrompt });
messages.push({ role: "user", content: input });
const result = await chatCompletion(agent.model, messages, {
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
max_tokens: agent.maxTokens ?? 2048,
});
const text = result.choices[0]?.message?.content ?? "";
return { output: text, success: true };
}
case "container": {
// Placeholder: in production this would call Docker SDK / Gateway
const cfg = node.containerConfig as any;
return {
output: `[Container ${cfg?.image ?? "unknown"}] executed with input length=${input.length}`,
success: true,
};
}
case "condition": {
const expr = node.conditionExpr ?? "true";
// Simple safe eval: only allow basic boolean expressions
const result = expr.trim().toLowerCase() === "true" || input.trim().length > 0;
return { output: result ? "true" : "false", success: true };
}
case "trigger":
case "output":
return { output: input, success: true };
default:
return { output: input, success: true };
}
} catch (err: any) {
return { output: "", success: false, error: err.message };
}
}
/**
* Execute a full workflow from its trigger node(s) following edges.
* Updates workflowRuns in real-time so the dashboard can poll progress.
*/
export async function executeWorkflow(workflowId: number, userInput?: string): Promise<WorkflowRun | null> {
const wf = await getWorkflowById(workflowId);
if (!wf) return null;
const run = await createRun(workflowId, userInput);
if (!run) return null;
const { nodes, edges } = wf;
// Build adjacency: sourceNodeKey → [targetNodeKey, …]
const adj: Record<string, string[]> = {};
for (const e of edges) {
if (!adj[e.sourceNodeKey]) adj[e.sourceNodeKey] = [];
adj[e.sourceNodeKey].push(e.targetNodeKey);
}
// Find trigger / start nodes (no incoming edges, or kind=trigger)
const incomingSet = new Set(edges.map((e) => e.targetNodeKey));
const startNodes = nodes.filter(
(n) => n.kind === "trigger" || !incomingSet.has(n.nodeKey)
);
const nodeMap: Record<string, WorkflowNode> = {};
for (const n of nodes) nodeMap[n.nodeKey] = n;
// Mark run as running
await updateRun(run.runKey, { status: "running", startedAt: new Date() } as any);
const nodeResults: Record<string, any> = {};
const visited = new Set<string>();
// BFS execution
const queue: Array<{ nodeKey: string; input: string }> = startNodes.map((n) => ({
nodeKey: n.nodeKey,
input: userInput ?? "",
}));
let finalOutput = "";
let hasError = false;
while (queue.length > 0) {
const { nodeKey, input } = queue.shift()!;
if (visited.has(nodeKey)) continue;
visited.add(nodeKey);
const node = nodeMap[nodeKey];
if (!node) continue;
// Update current node
nodeResults[nodeKey] = { status: "running", startedAt: new Date().toISOString() };
await updateRun(run.runKey, { currentNodeKey: nodeKey, nodeResults } as any);
const start = Date.now();
const result = await executeNode(node, input, run.runKey);
const durationMs = Date.now() - start;
nodeResults[nodeKey] = {
status: result.success ? "success" : "failed",
output: result.output,
durationMs,
error: result.error,
startedAt: nodeResults[nodeKey].startedAt,
finishedAt: new Date().toISOString(),
};
await updateRun(run.runKey, { nodeResults } as any);
if (!result.success) {
hasError = true;
continue; // don't propagate to children on failure
}
// For condition nodes: only propagate if result is "true"
if (node.kind === "condition" && result.output !== "true") {
continue;
}
finalOutput = result.output;
// Enqueue children
const children = adj[nodeKey] ?? [];
for (const childKey of children) {
if (!visited.has(childKey)) {
queue.push({ nodeKey: childKey, input: result.output });
}
}
}
// Mark remaining unvisited nodes as skipped
for (const n of nodes) {
if (!nodeResults[n.nodeKey]) {
nodeResults[n.nodeKey] = { status: "skipped" };
}
}
const totalDurationMs = run.startedAt ? Date.now() - new Date(run.startedAt as any).getTime() : 0;
await updateRun(run.runKey, {
status: hasError ? "failed" : "success",
nodeResults,
output: finalOutput,
totalDurationMs,
finishedAt: new Date(),
currentNodeKey: null,
errorMessage: hasError ? "One or more nodes failed" : null,
} as any);
return getRunByKey(run.runKey);
}
/**
* Execute a single node inside a workflow (for testing individual blocks).
*/
export async function executeSingleNode(
workflowId: number,
nodeKey: string,
input: string,
): Promise<{ output: string; success: boolean; durationMs: number; error?: string }> {
const db = await getDb();
if (!db) return { output: "", success: false, durationMs: 0, error: "DB unavailable" };
const [node] = await db
.select()
.from(workflowNodes)
.where(and(eq(workflowNodes.workflowId, workflowId), eq(workflowNodes.nodeKey, nodeKey)))
.limit(1);
if (!node) return { output: "", success: false, durationMs: 0, error: "Node not found" };
const start = Date.now();
const result = await executeNode(node, input, `test_${nanoid(8)}`);
return { ...result, durationMs: Date.now() - start };
}
/**
* Cancel a running workflow run
*/
export async function cancelRun(runKey: string): Promise<boolean> {
const db = await getDb();
if (!db) return false;
await db
.update(workflowRuns)
.set({ status: "cancelled", finishedAt: new Date() } as any)
.where(eq(workflowRuns.runKey, runKey));
return true;
}
/**
* Get aggregated stats for a workflow
*/
export async function getWorkflowStats(workflowId: number) {
const db = await getDb();
if (!db) return null;
const runs = await db
.select()
.from(workflowRuns)
.where(eq(workflowRuns.workflowId, workflowId))
.orderBy(desc(workflowRuns.createdAt))
.limit(100);
const total = runs.length;
const success = runs.filter((r) => r.status === "success").length;
const failed = runs.filter((r) => r.status === "failed").length;
const running = runs.filter((r) => r.status === "running").length;
const avgDuration = total > 0
? Math.round(runs.reduce((s, r) => s + (r.totalDurationMs ?? 0), 0) / total)
: 0;
return {
totalRuns: total,
successRuns: success,
failedRuns: failed,
runningRuns: running,
successRate: total > 0 ? Math.round((success / total) * 100) : 0,
avgDurationMs: avgDuration,
lastRun: runs[0] ?? null,
};
}