flowyml 1.4.0__py3-none-any.whl → 1.6.0__py3-none-any.whl

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.
Files changed (51) hide show
  1. flowyml/__init__.py +2 -1
  2. flowyml/assets/featureset.py +30 -5
  3. flowyml/assets/metrics.py +47 -4
  4. flowyml/cli/main.py +21 -0
  5. flowyml/cli/models.py +444 -0
  6. flowyml/cli/rich_utils.py +95 -0
  7. flowyml/core/checkpoint.py +6 -1
  8. flowyml/core/conditional.py +104 -0
  9. flowyml/core/display.py +525 -0
  10. flowyml/core/execution_status.py +1 -0
  11. flowyml/core/executor.py +201 -8
  12. flowyml/core/orchestrator.py +500 -7
  13. flowyml/core/pipeline.py +301 -11
  14. flowyml/core/project.py +4 -1
  15. flowyml/core/scheduler.py +225 -81
  16. flowyml/core/versioning.py +13 -4
  17. flowyml/registry/model_registry.py +1 -1
  18. flowyml/storage/sql.py +53 -13
  19. flowyml/ui/backend/main.py +2 -0
  20. flowyml/ui/backend/routers/assets.py +36 -0
  21. flowyml/ui/backend/routers/execution.py +2 -2
  22. flowyml/ui/backend/routers/runs.py +211 -0
  23. flowyml/ui/backend/routers/stats.py +2 -2
  24. flowyml/ui/backend/routers/websocket.py +121 -0
  25. flowyml/ui/frontend/dist/assets/index-By4trVyv.css +1 -0
  26. flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +630 -0
  27. flowyml/ui/frontend/dist/index.html +2 -2
  28. flowyml/ui/frontend/package-lock.json +289 -0
  29. flowyml/ui/frontend/package.json +1 -0
  30. flowyml/ui/frontend/src/app/compare/page.jsx +213 -0
  31. flowyml/ui/frontend/src/app/experiments/compare/page.jsx +289 -0
  32. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -1
  33. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +418 -203
  34. flowyml/ui/frontend/src/app/runs/page.jsx +64 -3
  35. flowyml/ui/frontend/src/app/settings/page.jsx +1 -1
  36. flowyml/ui/frontend/src/app/tokens/page.jsx +8 -6
  37. flowyml/ui/frontend/src/components/ArtifactViewer.jsx +159 -0
  38. flowyml/ui/frontend/src/components/NavigationTree.jsx +26 -9
  39. flowyml/ui/frontend/src/components/PipelineGraph.jsx +69 -28
  40. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +42 -14
  41. flowyml/ui/frontend/src/router/index.jsx +4 -0
  42. flowyml/ui/server_manager.py +181 -0
  43. flowyml/ui/utils.py +63 -1
  44. flowyml/utils/config.py +7 -0
  45. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/METADATA +5 -3
  46. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/RECORD +49 -41
  47. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +0 -1
  48. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +0 -592
  49. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/WHEEL +0 -0
  50. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/entry_points.txt +0 -0
  51. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useState } from 'react';
2
2
  import { fetchApi } from '../../utils/api';
3
- import { Link, useSearchParams } from 'react-router-dom';
4
- import { PlayCircle, Clock, CheckCircle, XCircle, Activity, ArrowRight, Calendar, Filter, RefreshCw, Layout } from 'lucide-react';
3
+ import { Link, useSearchParams, useNavigate } from 'react-router-dom';
4
+ import { PlayCircle, Clock, CheckCircle, XCircle, Activity, ArrowRight, Calendar, Filter, RefreshCw, Layout, GitCompare, CheckSquare, X } from 'lucide-react';
5
5
  import { Card } from '../../components/ui/Card';
6
6
  import { Badge } from '../../components/ui/Badge';
7
7
  import { Button } from '../../components/ui/Button';
@@ -17,8 +17,14 @@ export function Runs() {
17
17
  const [loading, setLoading] = useState(true);
18
18
  const [selectedRun, setSelectedRun] = useState(null);
19
19
  const [searchParams] = useSearchParams();
20
+ const navigate = useNavigate();
20
21
  const { selectedProject } = useProject();
21
22
 
23
+ // Selection & Comparison State
24
+ const [selectionMode, setSelectionMode] = useState('single');
25
+ const [selectedRunIds, setSelectedRunIds] = useState([]);
26
+
27
+
22
28
  // Filter states
23
29
  const [statusFilter, setStatusFilter] = useState('all');
24
30
  const pipelineFilter = searchParams.get('pipeline');
@@ -49,7 +55,33 @@ export function Runs() {
49
55
  }, [selectedProject, pipelineFilter]);
50
56
 
51
57
  const handleRunSelect = (run) => {
52
- setSelectedRun(run);
58
+ if (selectionMode === 'single') {
59
+ setSelectedRun(run);
60
+ }
61
+ };
62
+
63
+ const toggleSelectionMode = () => {
64
+ if (selectionMode === 'single') {
65
+ setSelectionMode('multi');
66
+ setSelectedRunIds([]);
67
+ } else {
68
+ setSelectionMode('single');
69
+ setSelectedRunIds([]);
70
+ }
71
+ };
72
+
73
+ const handleMultiSelect = (runId, checked) => {
74
+ if (checked) {
75
+ setSelectedRunIds(prev => [...prev, runId]);
76
+ } else {
77
+ setSelectedRunIds(prev => prev.filter(id => id !== runId));
78
+ }
79
+ };
80
+
81
+ const handleCompare = () => {
82
+ if (selectedRunIds.length >= 2) {
83
+ navigate(`/compare?runs=${selectedRunIds.join(',')}`);
84
+ }
53
85
  };
54
86
 
55
87
  return (
@@ -67,6 +99,32 @@ export function Runs() {
67
99
  </p>
68
100
  </div>
69
101
  <div className="flex items-center gap-3">
102
+ {selectionMode === 'multi' ? (
103
+ <>
104
+ <span className="text-sm text-slate-500 mr-2">
105
+ {selectedRunIds.length} selected
106
+ </span>
107
+ <Button
108
+ size="sm"
109
+ variant="primary"
110
+ disabled={selectedRunIds.length < 2}
111
+ onClick={handleCompare}
112
+ >
113
+ <GitCompare size={16} className="mr-2" />
114
+ Compare
115
+ </Button>
116
+ <Button variant="ghost" size="sm" onClick={toggleSelectionMode}>
117
+ <X size={16} className="mr-2" />
118
+ Cancel
119
+ </Button>
120
+ </>
121
+ ) : (
122
+ <Button variant="outline" size="sm" onClick={toggleSelectionMode}>
123
+ <CheckSquare size={16} className="mr-2" />
124
+ Select to Compare
125
+ </Button>
126
+ )}
127
+
70
128
  <Button variant="outline" size="sm" onClick={fetchRuns} disabled={loading}>
71
129
  <RefreshCw size={16} className={`mr-2 ${loading ? 'animate-spin' : ''}`} />
72
130
  Refresh
@@ -95,6 +153,9 @@ export function Runs() {
95
153
  projectId={selectedProject}
96
154
  onSelect={handleRunSelect}
97
155
  selectedId={selectedRun?.run_id}
156
+ selectionMode={selectionMode}
157
+ selectedIds={selectedRunIds}
158
+ onMultiSelect={handleMultiSelect}
98
159
  />
99
160
  </div>
100
161
  </div>
@@ -146,7 +146,7 @@ export function Settings() {
146
146
  </Button>
147
147
  </Card>
148
148
  ) : (
149
- tokens.map((token) => (
149
+ Array.isArray(tokens) && tokens.map((token) => (
150
150
  <Card key={token.id} className="hover:shadow-lg transition-all duration-200">
151
151
  <div className="flex items-center justify-between gap-4">
152
152
  <div className="flex-1 min-w-0">
@@ -42,6 +42,7 @@ export function TokenManagement() {
42
42
  return;
43
43
  }
44
44
  const data = await res.json();
45
+ console.log("Tokens fetched:", data);
45
46
  setTokens(data.tokens || []);
46
47
  } catch (err) {
47
48
  console.error('Failed to fetch tokens:', err);
@@ -127,7 +128,7 @@ export function TokenManagement() {
127
128
  )}
128
129
 
129
130
  {/* Tokens List */}
130
- {tokens.length > 0 && (
131
+ {Array.isArray(tokens) && tokens.length > 0 && (
131
132
  <Card>
132
133
  <CardHeader>
133
134
  <div className="flex items-center justify-between">
@@ -210,7 +211,7 @@ function TokenItem({ token, onRevoke }) {
210
211
  const handleRevoke = async () => {
211
212
  try {
212
213
  // This would need to be implemented in the backend
213
- await fetch(`/ api / execution / tokens / ${token.name} `, {
214
+ await fetchApi(`/api/execution/tokens/${token.name}`, {
214
215
  method: 'DELETE'
215
216
  });
216
217
  onRevoke();
@@ -232,7 +233,7 @@ function TokenItem({ token, onRevoke }) {
232
233
  )}
233
234
  </div>
234
235
  <div className="flex flex-wrap gap-2 mb-3">
235
- {token.permissions?.length ? (
236
+ {Array.isArray(token.permissions) && token.permissions.length > 0 ? (
236
237
  token.permissions.map(perm => (
237
238
  <PermissionChip key={perm} perm={perm} />
238
239
  ))
@@ -347,9 +348,10 @@ function CreateTokenModal({ onClose, onCreate }) {
347
348
  try {
348
349
  const res = await fetchApi('/api/projects/');
349
350
  const data = await res.json();
350
- setProjects(data || []);
351
+ setProjects(data.projects || []);
351
352
  } catch (err) {
352
353
  console.error('Failed to fetch projects:', err);
354
+ setProjects([]);
353
355
  } finally {
354
356
  setLoadingProjects(false);
355
357
  }
@@ -364,7 +366,7 @@ function CreateTokenModal({ onClose, onCreate }) {
364
366
  setLoading(true);
365
367
  setError(null);
366
368
  try {
367
- const res = await fetch('/api/execution/tokens', {
369
+ const res = await fetchApi('/api/execution/tokens', {
368
370
  method: 'POST',
369
371
  headers: { 'Content-Type': 'application/json' },
370
372
  body: JSON.stringify({
@@ -448,7 +450,7 @@ function CreateTokenModal({ onClose, onCreate }) {
448
450
  className="w-full px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-slate-900 text-slate-900 dark:text-white"
449
451
  >
450
452
  <option value="">All Projects</option>
451
- {projects.map(proj => (
453
+ {Array.isArray(projects) && projects.map(proj => (
452
454
  <option key={proj.name} value={proj.name}>
453
455
  {proj.name}
454
456
  </option>
@@ -0,0 +1,159 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { fetchApi } from '../utils/api';
3
+ import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
4
+ import json from 'react-syntax-highlighter/dist/esm/languages/hljs/json';
5
+ import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python';
6
+ import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs';
7
+ import {
8
+ Image,
9
+ FileText,
10
+ Code,
11
+ Table as TableIcon,
12
+ AlertCircle,
13
+ Download
14
+ } from 'lucide-react';
15
+ import { Button } from './ui/Button'; // Assuming you have a Button component, or use native button if not
16
+
17
+ SyntaxHighlighter.registerLanguage('json', json);
18
+ SyntaxHighlighter.registerLanguage('python', python);
19
+
20
+ export function ArtifactViewer({ artifact }) {
21
+ const [content, setContent] = useState(null);
22
+ const [contentType, setContentType] = useState(null);
23
+ const [loading, setLoading] = useState(false);
24
+ const [error, setError] = useState(null);
25
+
26
+ useEffect(() => {
27
+ if (!artifact?.artifact_id) return;
28
+ fetchContent();
29
+ }, [artifact]);
30
+
31
+ const fetchContent = async () => {
32
+ setLoading(true);
33
+ setError(null);
34
+ try {
35
+ // First try to infer type from artifact metadata
36
+ let type = 'text';
37
+ if (artifact.path) {
38
+ const ext = artifact.path.split('.').pop().toLowerCase();
39
+ if (['png', 'jpg', 'jpeg', 'svg', 'gif'].includes(ext)) type = 'image';
40
+ else if (['json'].includes(ext)) type = 'json';
41
+ else if (['csv', 'tsv'].includes(ext)) type = 'table';
42
+ }
43
+
44
+ // If it's a value-based artifact (no file path), use the value directly if possible
45
+ if (!artifact.path && artifact.value) {
46
+ try {
47
+ const parsed = JSON.parse(artifact.value);
48
+ setContent(parsed);
49
+ setContentType('json');
50
+ } catch {
51
+ setContent(artifact.value);
52
+ setContentType('text');
53
+ }
54
+ setLoading(false);
55
+ return;
56
+ }
57
+
58
+ // Fetch actual content from backend
59
+ const res = await fetchApi(`/api/assets/${artifact.artifact_id}/content`);
60
+ if (!res.ok) {
61
+ // If content fetch fails, fallback to 'value' preview if available
62
+ if (artifact.value) {
63
+ setContent(artifact.value);
64
+ setContentType('text');
65
+ } else {
66
+ throw new Error('Failed to load artifact content');
67
+ }
68
+ } else {
69
+ const blob = await res.blob();
70
+ const mime = blob.type;
71
+
72
+ if (mime.startsWith('image/') || type === 'image') {
73
+ const url = URL.createObjectURL(blob);
74
+ setContent(url);
75
+ setContentType('image');
76
+ } else if (mime.includes('json') || type === 'json') {
77
+ const text = await blob.text();
78
+ setContent(JSON.parse(text));
79
+ setContentType('json');
80
+ } else {
81
+ const text = await blob.text();
82
+ setContent(text);
83
+ setContentType('text');
84
+ }
85
+ }
86
+
87
+ } catch (err) {
88
+ console.error('Failed to load artifact:', err);
89
+ setError('Could not load full content. Showing metadata only.');
90
+ } finally {
91
+ setLoading(false);
92
+ }
93
+ };
94
+
95
+ if (loading) {
96
+ return (
97
+ <div className="flex flex-col items-center justify-center p-12 text-slate-400">
98
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
99
+ <p className="text-sm">Loading content...</p>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ if (error) {
105
+ return (
106
+ <div className="flex flex-col items-center justify-center p-8 text-rose-500 bg-rose-50 rounded-lg border border-rose-100">
107
+ <AlertCircle size={24} className="mb-2" />
108
+ <p className="text-sm font-medium">{error}</p>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ if (contentType === 'image') {
114
+ return (
115
+ <div className="flex flex-col items-center bg-slate-50 dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-700">
116
+ <div className="relative rounded-lg overflow-hidden shadow-sm bg-white dark:bg-black">
117
+ <img src={content} alt={artifact.name} className="max-w-full max-h-[60vh] object-contain" />
118
+ </div>
119
+ <p className="text-xs text-slate-500 dark:text-slate-400 mt-2 flex items-center gap-1">
120
+ <Image size={12} />
121
+ Image Preview
122
+ </p>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ if (contentType === 'json') {
128
+ return (
129
+ <div className="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700 shadow-sm">
130
+ <div className="bg-slate-50 dark:bg-slate-800 px-3 py-2 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2">
131
+ <Code size={14} className="text-slate-500 dark:text-slate-400" />
132
+ <span className="text-xs font-semibold text-slate-600 dark:text-slate-300">JSON Viewer</span>
133
+ </div>
134
+ <div className="max-h-[50vh] overflow-y-auto bg-white">
135
+ <SyntaxHighlighter
136
+ language="json"
137
+ style={docco}
138
+ customStyle={{ margin: 0, padding: '1rem', fontSize: '0.85rem' }}
139
+ >
140
+ {JSON.stringify(content, null, 2)}
141
+ </SyntaxHighlighter>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ // Default Text Viewer
148
+ return (
149
+ <div className="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700 shadow-sm">
150
+ <div className="bg-slate-50 dark:bg-slate-800 px-3 py-2 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2">
151
+ <FileText size={14} className="text-slate-500 dark:text-slate-400" />
152
+ <span className="text-xs font-semibold text-slate-600 dark:text-slate-300">Text Content</span>
153
+ </div>
154
+ <pre className="p-4 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200 text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[50vh] overflow-y-auto">
155
+ {typeof content === 'string' ? content : JSON.stringify(content, null, 2)}
156
+ </pre>
157
+ </div>
158
+ );
159
+ }
@@ -53,7 +53,10 @@ const TreeNode = ({
53
53
  badge,
54
54
  onClick,
55
55
  isActive = false,
56
- subLabel
56
+ subLabel,
57
+ checkable = false,
58
+ checked = false,
59
+ onCheck
57
60
  }) => {
58
61
  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
59
62
  const hasChildren = children && children.length > 0;
@@ -78,6 +81,18 @@ const TreeNode = ({
78
81
  onClick?.();
79
82
  }}
80
83
  >
84
+ {/* Checkbox for selection mode */}
85
+ {checkable && (
86
+ <div className="shrink-0 mr-1" onClick={(e) => e.stopPropagation()}>
87
+ <input
88
+ type="checkbox"
89
+ checked={checked}
90
+ onChange={(e) => onCheck?.(e.target.checked)}
91
+ className="w-4 h-4 rounded border-slate-300 text-primary-600 focus:ring-primary-500 cursor-pointer accent-blue-600"
92
+ />
93
+ </div>
94
+ )}
95
+
81
96
  <div className="flex items-center gap-1 text-slate-400 shrink-0">
82
97
  {hasChildren ? (
83
98
  <motion.div
@@ -135,7 +150,10 @@ export function NavigationTree({
135
150
  onSelect,
136
151
  selectedId,
137
152
  mode = 'experiments', // experiments, pipelines, runs
138
- className = ''
153
+ className = '',
154
+ selectionMode = 'single', // 'single' | 'multi'
155
+ selectedIds = [], // Array of IDs for multi-select
156
+ onMultiSelect // Callback for multi-select (id, checked)
139
157
  }) {
140
158
  const [data, setData] = useState({ projects: [], items: [] });
141
159
  const [loading, setLoading] = useState(true);
@@ -238,13 +256,6 @@ export function NavigationTree({
238
256
  );
239
257
 
240
258
  const renderExperiments = () => {
241
- const getRunsForExperiment = (expName) => {
242
- // This would ideally come from the API or be passed in,
243
- // but for now we might not have runs loaded here.
244
- // We'll just show the experiment node.
245
- return [];
246
- };
247
-
248
259
  const renderExperimentNode = (exp, level) => (
249
260
  <TreeNode
250
261
  key={exp.experiment_id}
@@ -253,6 +264,9 @@ export function NavigationTree({
253
264
  level={level}
254
265
  isActive={selectedId === exp.experiment_id}
255
266
  onClick={() => onSelect?.(exp)}
267
+ checkable={selectionMode === 'multi'}
268
+ checked={selectedIds?.includes(exp.experiment_id)}
269
+ onCheck={(checked) => onMultiSelect?.(exp.experiment_id, checked)}
256
270
  badge={
257
271
  <span className="text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-500 px-1.5 py-0.5 rounded-full">
258
272
  {exp.run_count || 0}
@@ -338,6 +352,9 @@ export function NavigationTree({
338
352
  status={run.status}
339
353
  isActive={selectedId === run.run_id}
340
354
  onClick={() => onSelect?.(run)}
355
+ checkable={selectionMode === 'multi'}
356
+ checked={selectedIds?.includes(run.run_id)}
357
+ onCheck={(checked) => onMultiSelect?.(run.run_id, checked)}
341
358
  />
342
359
  );
343
360
 
@@ -57,7 +57,7 @@ const getLayoutedElements = (nodes, edges, direction = 'TB') => {
57
57
  return { nodes, edges };
58
58
  };
59
59
 
60
- export function PipelineGraph({ dag, steps, selectedStep, onStepSelect }) {
60
+ export function PipelineGraph({ dag, steps, selectedStep, onStepSelect, onArtifactSelect }) {
61
61
  // Transform DAG data to ReactFlow format with Artifact Nodes
62
62
  const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
63
63
  if (!dag || !dag.nodes) return { nodes: [], edges: [] };
@@ -83,10 +83,37 @@ export function PipelineGraph({ dag, steps, selectedStep, onStepSelect }) {
83
83
  return id;
84
84
  };
85
85
 
86
+ // Map execution groups to colors
87
+ const groupColors = {};
88
+ const groupColorPalette = [
89
+ { bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-blue-400 dark:border-blue-500', text: 'text-blue-700 dark:text-blue-300', badge: 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300' },
90
+ { bg: 'bg-purple-50 dark:bg-purple-900/20', border: 'border-purple-400 dark:border-purple-500', text: 'text-purple-700 dark:text-purple-300', badge: 'bg-purple-100 dark:bg-purple-800 text-purple-700 dark:text-purple-300' },
91
+ { bg: 'bg-green-50 dark:bg-green-900/20', border: 'border-green-400 dark:border-green-500', text: 'text-green-700 dark:text-green-300', badge: 'bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-300' },
92
+ { bg: 'bg-orange-50 dark:bg-orange-900/20', border: 'border-orange-400 dark:border-orange-500', text: 'text-orange-700 dark:text-orange-300', badge: 'bg-orange-100 dark:bg-orange-800 text-orange-700 dark:text-orange-300' },
93
+ { bg: 'bg-pink-50 dark:bg-pink-900/20', border: 'border-pink-400 dark:border-pink-500', text: 'text-pink-700 dark:text-pink-300', badge: 'bg-pink-100 dark:bg-pink-800 text-pink-700 dark:text-pink-300' },
94
+ { bg: 'bg-cyan-50 dark:bg-cyan-900/20', border: 'border-cyan-400 dark:border-cyan-500', text: 'text-cyan-700 dark:text-cyan-300', badge: 'bg-cyan-100 dark:bg-cyan-800 text-cyan-700 dark:text-cyan-300' },
95
+ ];
96
+
97
+ // First pass: collect all execution groups
98
+ const executionGroups = new Set();
99
+ dag.nodes.forEach(node => {
100
+ const stepData = steps?.[node.id] || {};
101
+ if (stepData.execution_group) {
102
+ executionGroups.add(stepData.execution_group);
103
+ }
104
+ });
105
+
106
+ // Assign colors to groups
107
+ Array.from(executionGroups).forEach((group, idx) => {
108
+ groupColors[group] = groupColorPalette[idx % groupColorPalette.length];
109
+ });
110
+
86
111
  // 1. Create Step Nodes and Connections
87
112
  dag.nodes.forEach(node => {
88
113
  const stepData = steps?.[node.id] || {};
89
114
  const status = stepData.success ? 'success' : stepData.error ? 'failed' : stepData.running ? 'running' : 'pending';
115
+ const executionGroup = stepData.execution_group;
116
+ const groupColor = executionGroup ? groupColors[executionGroup] : null;
90
117
 
91
118
  nodes.push({
92
119
  id: node.id,
@@ -96,7 +123,9 @@ export function PipelineGraph({ dag, steps, selectedStep, onStepSelect }) {
96
123
  status,
97
124
  duration: stepData.duration,
98
125
  cached: stepData.cached,
99
- selected: selectedStep === node.id
126
+ selected: selectedStep === node.id,
127
+ execution_group: executionGroup,
128
+ groupColor: groupColor
100
129
  }
101
130
  });
102
131
 
@@ -159,8 +188,10 @@ export function PipelineGraph({ dag, steps, selectedStep, onStepSelect }) {
159
188
  const onNodeClick = useCallback((event, node) => {
160
189
  if (node.type === 'step' && onStepSelect) {
161
190
  onStepSelect(node.id);
191
+ } else if (node.type === 'artifact' && onArtifactSelect) {
192
+ onArtifactSelect(node.data.label);
162
193
  }
163
- }, [onStepSelect]);
194
+ }, [onStepSelect, onArtifactSelect]);
164
195
 
165
196
  const nodeTypes = useMemo(() => ({
166
197
  step: CustomStepNode,
@@ -168,7 +199,7 @@ export function PipelineGraph({ dag, steps, selectedStep, onStepSelect }) {
168
199
  }), []);
169
200
 
170
201
  return (
171
- <div className="w-full h-full bg-slate-50/50 rounded-xl border border-slate-200 overflow-hidden">
202
+ <div className="w-full h-full bg-slate-50/50 dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
172
203
  <ReactFlow
173
204
  nodes={nodes}
174
205
  edges={edges}
@@ -199,45 +230,48 @@ function CustomStepNode({ data }) {
199
230
  const statusConfig = {
200
231
  success: {
201
232
  icon: <CheckCircle size={18} />,
202
- color: 'text-emerald-600',
203
- bg: 'bg-white',
204
- border: 'border-emerald-500',
205
- ring: 'ring-emerald-200',
206
- shadow: 'shadow-emerald-100'
233
+ color: 'text-emerald-600 dark:text-emerald-400',
234
+ bg: 'bg-white dark:bg-slate-800',
235
+ border: 'border-emerald-500 dark:border-emerald-500',
236
+ ring: 'ring-emerald-200 dark:ring-emerald-900',
237
+ shadow: 'shadow-emerald-100 dark:shadow-none'
207
238
  },
208
239
  failed: {
209
240
  icon: <XCircle size={18} />,
210
- color: 'text-rose-600',
211
- bg: 'bg-white',
212
- border: 'border-rose-500',
213
- ring: 'ring-rose-200',
214
- shadow: 'shadow-rose-100'
241
+ color: 'text-rose-600 dark:text-rose-400',
242
+ bg: 'bg-white dark:bg-slate-800',
243
+ border: 'border-rose-500 dark:border-rose-500',
244
+ ring: 'ring-rose-200 dark:ring-rose-900',
245
+ shadow: 'shadow-rose-100 dark:shadow-none'
215
246
  },
216
247
  running: {
217
248
  icon: <Loader size={18} className="animate-spin" />,
218
- color: 'text-amber-600',
219
- bg: 'bg-white',
220
- border: 'border-amber-500',
221
- ring: 'ring-amber-200',
222
- shadow: 'shadow-amber-100'
249
+ color: 'text-amber-600 dark:text-amber-400',
250
+ bg: 'bg-white dark:bg-slate-800',
251
+ border: 'border-amber-500 dark:border-amber-500',
252
+ ring: 'ring-amber-200 dark:ring-amber-900',
253
+ shadow: 'shadow-amber-100 dark:shadow-none'
223
254
  },
224
255
  pending: {
225
256
  icon: <Clock size={18} />,
226
- color: 'text-slate-400',
227
- bg: 'bg-slate-50',
228
- border: 'border-slate-300',
229
- ring: 'ring-slate-200',
230
- shadow: 'shadow-slate-100'
257
+ color: 'text-slate-400 dark:text-slate-500',
258
+ bg: 'bg-slate-50 dark:bg-slate-800/50',
259
+ border: 'border-slate-300 dark:border-slate-700',
260
+ ring: 'ring-slate-200 dark:ring-slate-800',
261
+ shadow: 'shadow-slate-100 dark:shadow-none'
231
262
  }
232
263
  };
233
264
 
234
265
  const config = statusConfig[data.status] || statusConfig.pending;
266
+ const groupColor = data.groupColor;
267
+ const hasGroup = data.execution_group && groupColor;
235
268
 
236
269
  return (
237
270
  <div
238
271
  className={`
239
272
  relative px-4 py-3 rounded-lg border-2 transition-all duration-200
240
- ${config.bg} ${config.border}
273
+ ${hasGroup ? groupColor.bg : config.bg}
274
+ ${hasGroup ? groupColor.border : config.border}
241
275
  ${data.selected ? `ring-4 ${config.ring} shadow-lg` : `hover:shadow-md ${config.shadow}`}
242
276
  `}
243
277
  style={{ width: stepNodeWidth, height: stepNodeHeight }}
@@ -250,10 +284,17 @@ function CustomStepNode({ data }) {
250
284
  {config.icon}
251
285
  </div>
252
286
  <div className="min-w-0 flex-1">
253
- <h3 className="font-bold text-slate-900 text-sm truncate" title={data.label}>
287
+ <h3 className={`font-bold text-sm truncate ${hasGroup ? groupColor.text : 'text-slate-900 dark:text-white'}`} title={data.label}>
254
288
  {data.label}
255
289
  </h3>
256
- <p className="text-xs text-slate-500 capitalize">{data.status}</p>
290
+ <div className="flex items-center gap-2 mt-0.5">
291
+ <p className="text-xs text-slate-500 capitalize">{data.status}</p>
292
+ {hasGroup && (
293
+ <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${groupColor.badge}`}>
294
+ {data.execution_group}
295
+ </span>
296
+ )}
297
+ </div>
257
298
  </div>
258
299
  </div>
259
300
 
@@ -343,7 +384,7 @@ function CustomArtifactNode({ data }) {
343
384
 
344
385
  return (
345
386
  <div
346
- className={`px-3 py-2 rounded-lg ${style.bgColor} border-2 ${style.borderColor} flex items-center justify-center gap-2 shadow-md hover:shadow-lg transition-all min-w-[140px]`}
387
+ className={`px-3 py-2 rounded-lg ${style.bgColor} border-2 ${style.borderColor} flex items-center justify-center gap-2 shadow-md hover:shadow-lg transition-all min-w-[140px] cursor-pointer`}
347
388
  style={{ height: artifactNodeHeight }}
348
389
  >
349
390
  <Handle type="target" position={Position.Top} className="!bg-slate-400 !w-2 !h-2" />