mcpgraph-ux 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,22 +18,87 @@ import dagre from 'dagre';
18
18
  import 'reactflow/dist/style.css';
19
19
  import styles from './GraphVisualization.module.css';
20
20
 
21
+ export type NodeExecutionState = 'pending' | 'running' | 'completed' | 'error' | 'paused' | 'stopped';
22
+
23
+ export interface NodeExecutionStatus {
24
+ nodeId: string;
25
+ state: NodeExecutionState;
26
+ startTime?: number;
27
+ endTime?: number;
28
+ duration?: number;
29
+ error?: string;
30
+ }
31
+
32
+ interface NodeData {
33
+ label: string;
34
+ nodeType: string;
35
+ tool?: string;
36
+ server?: string;
37
+ args?: Record<string, unknown>;
38
+ transform?: { expr: string };
39
+ conditions?: Array<{ rule?: unknown; target: string }>;
40
+ [key: string]: unknown;
41
+ }
42
+
21
43
  interface GraphVisualizationProps {
22
44
  nodes: Node[];
23
45
  edges: Edge[];
24
46
  selectedTool?: string | null;
47
+ executionState?: Map<string, NodeExecutionStatus>;
48
+ highlightedNode?: string | null;
49
+ breakpoints?: Set<string>;
50
+ onToggleBreakpoint?: (nodeId: string) => void;
51
+ onNodeClick?: (nodeId: string) => void;
52
+ currentNodeId?: string | null;
25
53
  }
26
54
 
27
- // Custom node styles based on node type
28
- const getNodeStyle = (nodeType: string) => {
29
- const baseStyle = {
55
+ // Custom node styles based on node type and execution state
56
+ const getNodeStyle = (nodeType: string, executionState?: NodeExecutionState) => {
57
+ const baseStyle: React.CSSProperties = {
30
58
  padding: '10px',
31
59
  borderRadius: '8px',
32
- border: '2px solid',
60
+ borderStyle: 'solid',
61
+ borderWidth: '2px',
33
62
  fontSize: '12px',
34
63
  fontWeight: 500,
64
+ transition: 'all 0.3s ease',
65
+ boxSizing: 'border-box', // Include border in width/height to prevent shifting
35
66
  };
36
67
 
68
+ // Override colors based on execution state
69
+ if (executionState === 'running') {
70
+ return {
71
+ ...baseStyle,
72
+ backgroundColor: '#fff9c4',
73
+ borderColor: '#fbc02d',
74
+ color: '#f57f17',
75
+ boxShadow: '0 0 10px rgba(251, 192, 45, 0.5)',
76
+ animation: 'pulse 1.5s ease-in-out infinite',
77
+ };
78
+ } else if (executionState === 'paused') {
79
+ return {
80
+ ...baseStyle,
81
+ backgroundColor: '#e0f2f7',
82
+ borderColor: '#00bcd4',
83
+ color: '#006064',
84
+ boxShadow: '0 0 0 3px #00bcd4',
85
+ };
86
+ } else if (executionState === 'completed') {
87
+ return {
88
+ ...baseStyle,
89
+ backgroundColor: '#c8e6c9',
90
+ borderColor: '#66bb6a',
91
+ color: '#2e7d32',
92
+ };
93
+ } else if (executionState === 'error') {
94
+ return {
95
+ ...baseStyle,
96
+ backgroundColor: '#ffcdd2',
97
+ borderColor: '#ef5350',
98
+ color: '#c62828',
99
+ };
100
+ }
101
+
37
102
  switch (nodeType) {
38
103
  case 'entry':
39
104
  return {
@@ -80,27 +145,235 @@ const getNodeStyle = (nodeType: string) => {
80
145
  }
81
146
  };
82
147
 
83
- // Custom node component with left/right handles for horizontal flow
148
+ // Node type icon component
149
+ function NodeTypeIcon({ nodeType }: { nodeType: string }) {
150
+ const iconStyle: React.CSSProperties = {
151
+ width: '1.5em',
152
+ height: '1.5em',
153
+ color: '#000',
154
+ flexShrink: 0,
155
+ display: 'inline-block',
156
+ };
157
+
158
+ switch (nodeType) {
159
+ case 'mcp':
160
+ return (
161
+ <svg
162
+ fill="currentColor"
163
+ fillRule="evenodd"
164
+ height="1.5em"
165
+ style={iconStyle}
166
+ viewBox="0 0 24 24"
167
+ width="1.5em"
168
+ xmlns="http://www.w3.org/2000/svg"
169
+ >
170
+ <title>ModelContextProtocol</title>
171
+ <path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path>
172
+ <path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path>
173
+ </svg>
174
+ );
175
+ case 'entry':
176
+ return (
177
+ <svg
178
+ fill="currentColor"
179
+ height="1.5em"
180
+ style={iconStyle}
181
+ viewBox="0 0 24 24"
182
+ width="1.5em"
183
+ xmlns="http://www.w3.org/2000/svg"
184
+ >
185
+ <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2"/>
186
+ <path d="M10 8l6 4-6 4V8z" />
187
+ </svg>
188
+ );
189
+ case 'exit':
190
+ return (
191
+ <svg
192
+ fill="currentColor"
193
+ height="1.5em"
194
+ style={iconStyle}
195
+ viewBox="0 0 24 24"
196
+ width="1.5em"
197
+ xmlns="http://www.w3.org/2000/svg"
198
+ >
199
+ <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2"/>
200
+ <rect x="9" y="9" width="6" height="6" fill="currentColor"/>
201
+ </svg>
202
+ );
203
+ case 'transform':
204
+ return (
205
+ <svg
206
+ fill="currentColor"
207
+ height="1.5em"
208
+ style={iconStyle}
209
+ viewBox="0 0 24 24"
210
+ width="1.5em"
211
+ xmlns="http://www.w3.org/2000/svg"
212
+ >
213
+ <path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z" />
214
+ </svg>
215
+ );
216
+ case 'switch':
217
+ return (
218
+ <svg
219
+ fill="currentColor"
220
+ height="1.5em"
221
+ style={iconStyle}
222
+ viewBox="0 0 24 24"
223
+ width="1.5em"
224
+ xmlns="http://www.w3.org/2000/svg"
225
+ >
226
+ <path d="M12 2L2 12l10 10 10-10L12 2zm0 2.83L19.17 12 12 19.17 4.83 12 12 4.83z" />
227
+ </svg>
228
+ );
229
+ default:
230
+ return null;
231
+ }
232
+ }
233
+
234
+ // Custom node component with top/bottom handles for vertical flow
84
235
  function CustomNode({ data }: { data: any }) {
85
236
  const nodeType = data.nodeType || 'unknown';
86
- const style = getNodeStyle(nodeType);
237
+ const executionState = data.executionState as NodeExecutionState | undefined;
238
+ const isHighlighted = data.isHighlighted as boolean | undefined;
239
+ const isCurrentNode = data.isCurrentNode as boolean | undefined;
240
+ const hasBreakpoint = data.hasBreakpoint as boolean | undefined;
241
+ const onToggleBreakpoint = data.onToggleBreakpoint as ((nodeId: string) => void) | undefined;
242
+ const onNodeClick = data.onNodeClick as ((nodeId: string) => void) | undefined;
243
+ const nodeId = data.nodeId as string;
244
+
245
+ const baseStyle = getNodeStyle(nodeType, executionState);
246
+
247
+ // Make current node very obvious with outline (doesn't affect layout)
248
+ let style: React.CSSProperties = { ...baseStyle };
249
+ if (isCurrentNode) {
250
+ // Use outline to create thicker border effect without changing element size
251
+ style = {
252
+ ...baseStyle,
253
+ outline: '4px solid',
254
+ outlineColor: baseStyle.borderColor as string || '#333',
255
+ outlineOffset: '-2px', // Overlap with border to create thicker effect
256
+ zIndex: 10,
257
+ };
258
+ }
259
+
260
+ if (isHighlighted) {
261
+ // Use outline for highlight to avoid layout shift (outline doesn't affect box model)
262
+ style = {
263
+ ...style,
264
+ outline: isCurrentNode ? '4px solid rgba(255, 193, 7, 0.8)' : '3px solid rgba(255, 193, 7, 0.5)',
265
+ outlineOffset: isCurrentNode ? '-2px' : '2px',
266
+ boxShadow: '0 0 20px rgba(255, 193, 7, 0.3)',
267
+ zIndex: 10,
268
+ };
269
+ }
87
270
  const isEntry = nodeType === 'entry';
88
271
  const isExit = nodeType === 'exit';
89
272
 
273
+ // Add status indicator
274
+ const statusIndicator = executionState === 'running' ? '⏳' :
275
+ executionState === 'completed' ? '✓' :
276
+ executionState === 'error' ? '✗' : null;
277
+
278
+ const handleBreakpointClick = (e: React.MouseEvent) => {
279
+ e.stopPropagation();
280
+ if (onToggleBreakpoint && nodeId) {
281
+ onToggleBreakpoint(nodeId);
282
+ }
283
+ };
284
+
285
+ const handleNodeClick = (e: React.MouseEvent) => {
286
+ // Don't trigger node click if clicking on breakpoint
287
+ if ((e.target as HTMLElement).closest('button')) {
288
+ return;
289
+ }
290
+ if (onNodeClick && nodeId) {
291
+ onNodeClick(nodeId);
292
+ }
293
+ };
294
+
90
295
  return (
91
- <div style={style}>
296
+ <div style={{ ...style, cursor: onNodeClick ? 'pointer' : 'default' }} onClick={handleNodeClick}>
92
297
  {!isEntry && (
93
298
  <Handle
94
299
  type="target"
95
- position={Position.Left}
300
+ position={Position.Top}
96
301
  style={{ background: '#555' }}
97
302
  />
98
303
  )}
99
- <div>{data.label}</div>
304
+ <div style={{ display: 'flex', alignItems: 'center', gap: '5px', width: '100%' }}>
305
+ {/* Node type icon at far left */}
306
+ <NodeTypeIcon nodeType={nodeType} />
307
+
308
+ {/* Label */}
309
+ <span>{data.label}</span>
310
+
311
+ {/* Duration */}
312
+ {data.duration !== undefined && (
313
+ <span style={{ fontSize: '10px', opacity: 0.7 }}>
314
+ ({data.duration}ms)
315
+ </span>
316
+ )}
317
+
318
+ {/* Breakpoint button */}
319
+ {onToggleBreakpoint && (
320
+ <button
321
+ onClick={handleBreakpointClick}
322
+ style={{
323
+ width: 24,
324
+ height: 24,
325
+ borderRadius: '50%',
326
+ cursor: 'pointer',
327
+ padding: 0,
328
+ display: 'flex',
329
+ alignItems: 'center',
330
+ justifyContent: 'center',
331
+ border: 'none',
332
+ background: 'transparent',
333
+ flexShrink: 0,
334
+ }}
335
+ title={hasBreakpoint ? 'Click to remove breakpoint' : 'Click to add breakpoint'}
336
+ >
337
+ <span
338
+ style={{
339
+ width: 12,
340
+ height: 12,
341
+ borderRadius: '50%',
342
+ background: hasBreakpoint ? '#ef5350' : 'transparent',
343
+ border: hasBreakpoint ? '2px solid white' : '1px solid #999',
344
+ display: 'flex',
345
+ alignItems: 'center',
346
+ justifyContent: 'center',
347
+ fontSize: hasBreakpoint ? '8px' : '0',
348
+ boxShadow: hasBreakpoint ? '0 2px 4px rgba(0,0,0,0.2)' : 'none',
349
+ opacity: hasBreakpoint ? 1 : 0.5,
350
+ transition: 'opacity 0.2s, background 0.2s, border 0.2s',
351
+ }}
352
+ onMouseEnter={(e) => {
353
+ if (!hasBreakpoint) {
354
+ e.currentTarget.style.opacity = '1';
355
+ }
356
+ }}
357
+ onMouseLeave={(e) => {
358
+ if (!hasBreakpoint) {
359
+ e.currentTarget.style.opacity = '0.5';
360
+ }
361
+ }}
362
+ >
363
+ {hasBreakpoint && '●'}
364
+ </span>
365
+ </button>
366
+ )}
367
+
368
+ {/* Status indicators (running/completed/error) at far right */}
369
+ {statusIndicator && (
370
+ <span style={{ marginLeft: 'auto' }}>{statusIndicator}</span>
371
+ )}
372
+ </div>
100
373
  {!isExit && (
101
374
  <Handle
102
375
  type="source"
103
- position={Position.Right}
376
+ position={Position.Bottom}
104
377
  style={{ background: '#555' }}
105
378
  />
106
379
  )}
@@ -108,6 +381,7 @@ function CustomNode({ data }: { data: any }) {
108
381
  );
109
382
  }
110
383
 
384
+ // Define nodeTypes outside component to avoid React Flow warning
111
385
  const nodeTypes = {
112
386
  custom: CustomNode,
113
387
  };
@@ -118,9 +392,9 @@ const layoutNodes = (nodes: Node[], edges: Edge[]): Node[] => {
118
392
  const g = new dagre.graphlib.Graph();
119
393
  g.setDefaultEdgeLabel(() => ({}));
120
394
  g.setGraph({
121
- rankdir: 'LR', // Left to right layout
122
- nodesep: 100, // Horizontal spacing between nodes
123
- ranksep: 150, // Vertical spacing between ranks
395
+ rankdir: 'TB', // Top to bottom layout
396
+ nodesep: 50, // Vertical spacing between nodes
397
+ ranksep: 75, // Horizontal spacing between ranks
124
398
  marginx: 50,
125
399
  marginy: 50,
126
400
  });
@@ -128,7 +402,8 @@ const layoutNodes = (nodes: Node[], edges: Edge[]): Node[] => {
128
402
  // Add nodes to dagre graph
129
403
  nodes.forEach(node => {
130
404
  // Estimate node dimensions (dagre needs width/height)
131
- const nodeType = (node.data as any)?.nodeType || 'unknown';
405
+ const nodeData = node.data as NodeData;
406
+ const nodeType = nodeData?.nodeType || 'unknown';
132
407
  const label = node.id;
133
408
  // Rough estimate: ~10px per character + padding
134
409
  const width = Math.max(150, label.length * 8 + 40);
@@ -147,7 +422,8 @@ const layoutNodes = (nodes: Node[], edges: Edge[]): Node[] => {
147
422
 
148
423
  // Map dagre positions back to React Flow nodes
149
424
  const positionedNodes = nodes.map(node => {
150
- const nodeType = (node.data as any)?.nodeType || 'unknown';
425
+ const nodeData = node.data as NodeData;
426
+ const nodeType = nodeData?.nodeType || 'unknown';
151
427
  const dagreNode = g.node(node.id);
152
428
 
153
429
  return {
@@ -171,10 +447,38 @@ export default function GraphVisualization({
171
447
  nodes,
172
448
  edges,
173
449
  selectedTool,
450
+ executionState,
451
+ highlightedNode,
452
+ breakpoints,
453
+ onToggleBreakpoint,
454
+ onNodeClick,
455
+ currentNodeId,
174
456
  }: GraphVisualizationProps) {
175
457
  const layoutedNodes = useMemo(() => layoutNodes(nodes, edges), [nodes, edges]);
176
458
 
177
- const [flowNodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
459
+ // Merge execution state, highlight, and breakpoints into nodes
460
+ const nodesWithExecutionState = useMemo(() => {
461
+ return layoutedNodes.map(node => {
462
+ const status = executionState?.get(node.id);
463
+ const isCurrentNode = currentNodeId === node.id;
464
+ return {
465
+ ...node,
466
+ data: {
467
+ ...node.data,
468
+ nodeId: node.id,
469
+ executionState: status?.state || 'pending',
470
+ duration: status?.duration,
471
+ isHighlighted: highlightedNode === node.id,
472
+ isCurrentNode, // Mark current node explicitly
473
+ hasBreakpoint: breakpoints?.has(node.id) || false,
474
+ onToggleBreakpoint,
475
+ onNodeClick,
476
+ },
477
+ };
478
+ });
479
+ }, [layoutedNodes, executionState, highlightedNode, breakpoints, onToggleBreakpoint, onNodeClick, currentNodeId]);
480
+
481
+ const [flowNodes, setNodes, onNodesChange] = useNodesState(nodesWithExecutionState);
178
482
  const [flowEdges, setEdges, onEdgesChange] = useEdgesState(
179
483
  edges.map(edge => ({
180
484
  ...edge,
@@ -189,7 +493,26 @@ export default function GraphVisualization({
189
493
 
190
494
  useEffect(() => {
191
495
  const layouted = layoutNodes(nodes, edges);
192
- setNodes(layouted);
496
+ // Merge execution state, highlight, and breakpoints into nodes
497
+ const nodesWithState = layouted.map(node => {
498
+ const status = executionState?.get(node.id);
499
+ const isCurrentNode = currentNodeId === node.id;
500
+ return {
501
+ ...node,
502
+ data: {
503
+ ...node.data,
504
+ nodeId: node.id,
505
+ executionState: status?.state || 'pending',
506
+ duration: status?.duration,
507
+ isHighlighted: highlightedNode === node.id,
508
+ isCurrentNode, // Mark current node explicitly
509
+ hasBreakpoint: breakpoints?.has(node.id) || false,
510
+ onToggleBreakpoint,
511
+ onNodeClick,
512
+ },
513
+ };
514
+ });
515
+ setNodes(nodesWithState);
193
516
  setEdges(
194
517
  edges.map(edge => ({
195
518
  ...edge,
@@ -201,23 +524,29 @@ export default function GraphVisualization({
201
524
  style: { strokeWidth: 2 },
202
525
  }))
203
526
  );
204
- }, [nodes, edges, setNodes, setEdges]);
527
+ }, [nodes, edges, executionState, highlightedNode, breakpoints, onToggleBreakpoint, onNodeClick, currentNodeId, setNodes, setEdges]);
205
528
 
206
529
  // Filter nodes/edges for selected tool if provided
207
530
  const filteredNodes = useMemo(() => {
208
531
  if (!selectedTool) return flowNodes;
209
532
  return flowNodes.filter(node => {
210
- const data = node.data as any;
533
+ const data = node.data as NodeData;
211
534
  return (
212
535
  (data.nodeType === 'entry' && data.tool === selectedTool) ||
213
536
  (data.nodeType === 'exit' && data.tool === selectedTool) ||
214
537
  flowEdges.some(edge => {
215
538
  // Include nodes that are reachable from entry or lead to exit
216
539
  const entryNode = flowNodes.find(
217
- n => (n.data as any)?.nodeType === 'entry' && (n.data as any)?.tool === selectedTool
540
+ n => {
541
+ const nData = n.data as NodeData;
542
+ return nData?.nodeType === 'entry' && nData?.tool === selectedTool;
543
+ }
218
544
  );
219
545
  const exitNode = flowNodes.find(
220
- n => (n.data as any)?.nodeType === 'exit' && (n.data as any)?.tool === selectedTool
546
+ n => {
547
+ const nData = n.data as NodeData;
548
+ return nData?.nodeType === 'exit' && nData?.tool === selectedTool;
549
+ }
221
550
  );
222
551
 
223
552
  if (!entryNode || !exitNode) return false;
@@ -0,0 +1,115 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 12px;
5
+ }
6
+
7
+ .form {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 16px;
11
+ }
12
+
13
+ .inputs {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 16px;
17
+ }
18
+
19
+ .field {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: 4px;
23
+ }
24
+
25
+ .label {
26
+ font-weight: 600;
27
+ color: #333;
28
+ font-size: 0.9rem;
29
+ }
30
+
31
+ .checkboxLabel {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 8px;
35
+ font-weight: 600;
36
+ color: #333;
37
+ font-size: 0.9rem;
38
+ cursor: pointer;
39
+ }
40
+
41
+ .required {
42
+ color: #d32f2f;
43
+ margin-left: 4px;
44
+ }
45
+
46
+ .input {
47
+ padding: 8px 12px;
48
+ border: 1px solid #ddd;
49
+ border-radius: 4px;
50
+ font-size: 0.9rem;
51
+ font-family: inherit;
52
+ }
53
+
54
+ .input:focus {
55
+ outline: none;
56
+ border-color: #2196f3;
57
+ box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
58
+ }
59
+
60
+ .input:disabled {
61
+ background-color: #f5f5f5;
62
+ cursor: not-allowed;
63
+ }
64
+
65
+ .textarea {
66
+ padding: 8px 12px;
67
+ border: 1px solid #ddd;
68
+ border-radius: 4px;
69
+ font-size: 0.9rem;
70
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
71
+ resize: vertical;
72
+ min-height: 60px;
73
+ }
74
+
75
+ .textarea:focus {
76
+ outline: none;
77
+ border-color: #2196f3;
78
+ box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
79
+ }
80
+
81
+ .textarea:disabled {
82
+ background-color: #f5f5f5;
83
+ cursor: not-allowed;
84
+ }
85
+
86
+ .checkbox {
87
+ width: 18px;
88
+ height: 18px;
89
+ cursor: pointer;
90
+ }
91
+
92
+ .checkbox:disabled {
93
+ cursor: not-allowed;
94
+ }
95
+
96
+ .hint {
97
+ font-size: 0.85rem;
98
+ color: #666;
99
+ font-style: italic;
100
+ }
101
+
102
+ .loading {
103
+ padding: 1rem;
104
+ text-align: center;
105
+ color: #666;
106
+ }
107
+
108
+ .error {
109
+ padding: 1rem;
110
+ background-color: #ffebee;
111
+ color: #c62828;
112
+ border-radius: 4px;
113
+ border: 1px solid #ffcdd2;
114
+ }
115
+