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.
- flowyml/__init__.py +2 -1
- flowyml/assets/featureset.py +30 -5
- flowyml/assets/metrics.py +47 -4
- flowyml/cli/main.py +21 -0
- flowyml/cli/models.py +444 -0
- flowyml/cli/rich_utils.py +95 -0
- flowyml/core/checkpoint.py +6 -1
- flowyml/core/conditional.py +104 -0
- flowyml/core/display.py +525 -0
- flowyml/core/execution_status.py +1 -0
- flowyml/core/executor.py +201 -8
- flowyml/core/orchestrator.py +500 -7
- flowyml/core/pipeline.py +301 -11
- flowyml/core/project.py +4 -1
- flowyml/core/scheduler.py +225 -81
- flowyml/core/versioning.py +13 -4
- flowyml/registry/model_registry.py +1 -1
- flowyml/storage/sql.py +53 -13
- flowyml/ui/backend/main.py +2 -0
- flowyml/ui/backend/routers/assets.py +36 -0
- flowyml/ui/backend/routers/execution.py +2 -2
- flowyml/ui/backend/routers/runs.py +211 -0
- flowyml/ui/backend/routers/stats.py +2 -2
- flowyml/ui/backend/routers/websocket.py +121 -0
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +1 -0
- flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +630 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/package-lock.json +289 -0
- flowyml/ui/frontend/package.json +1 -0
- flowyml/ui/frontend/src/app/compare/page.jsx +213 -0
- flowyml/ui/frontend/src/app/experiments/compare/page.jsx +289 -0
- flowyml/ui/frontend/src/app/experiments/page.jsx +61 -1
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +418 -203
- flowyml/ui/frontend/src/app/runs/page.jsx +64 -3
- flowyml/ui/frontend/src/app/settings/page.jsx +1 -1
- flowyml/ui/frontend/src/app/tokens/page.jsx +8 -6
- flowyml/ui/frontend/src/components/ArtifactViewer.jsx +159 -0
- flowyml/ui/frontend/src/components/NavigationTree.jsx +26 -9
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +69 -28
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +42 -14
- flowyml/ui/frontend/src/router/index.jsx +4 -0
- flowyml/ui/server_manager.py +181 -0
- flowyml/ui/utils.py +63 -1
- flowyml/utils/config.py +7 -0
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/METADATA +5 -3
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/RECORD +49 -41
- flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +0 -1
- flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +0 -592
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/WHEEL +0 -0
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
${
|
|
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=
|
|
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
|
-
<
|
|
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" />
|