flowyml 1.3.0__py3-none-any.whl → 1.5.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/core/execution_status.py +1 -0
- flowyml/core/executor.py +175 -3
- flowyml/core/observability.py +7 -7
- flowyml/core/resources.py +12 -12
- flowyml/core/retry_policy.py +2 -2
- flowyml/core/scheduler.py +9 -9
- flowyml/core/scheduler_config.py +2 -3
- flowyml/core/submission_result.py +4 -4
- flowyml/stacks/bridge.py +9 -9
- flowyml/stacks/plugins.py +2 -2
- flowyml/stacks/registry.py +21 -0
- flowyml/storage/materializers/base.py +33 -0
- flowyml/storage/metadata.py +3 -1042
- flowyml/storage/remote.py +590 -0
- flowyml/storage/sql.py +951 -0
- flowyml/ui/backend/dependencies.py +28 -0
- flowyml/ui/backend/main.py +4 -79
- flowyml/ui/backend/routers/assets.py +170 -9
- flowyml/ui/backend/routers/client.py +6 -6
- flowyml/ui/backend/routers/execution.py +2 -2
- flowyml/ui/backend/routers/experiments.py +53 -6
- flowyml/ui/backend/routers/metrics.py +23 -68
- flowyml/ui/backend/routers/pipelines.py +19 -10
- flowyml/ui/backend/routers/runs.py +287 -9
- flowyml/ui/backend/routers/schedules.py +5 -21
- flowyml/ui/backend/routers/stats.py +14 -0
- flowyml/ui/backend/routers/traces.py +37 -53
- flowyml/ui/backend/routers/websocket.py +121 -0
- flowyml/ui/frontend/dist/assets/index-CBUXOWze.css +1 -0
- flowyml/ui/frontend/dist/assets/index-DF8dJaFL.js +629 -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 +26 -24
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +42 -14
- flowyml/ui/frontend/src/router/index.jsx +4 -0
- {flowyml-1.3.0.dist-info → flowyml-1.5.0.dist-info}/METADATA +3 -1
- {flowyml-1.3.0.dist-info → flowyml-1.5.0.dist-info}/RECORD +50 -42
- flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +0 -1
- flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +0 -592
- {flowyml-1.3.0.dist-info → flowyml-1.5.0.dist-info}/WHEEL +0 -0
- {flowyml-1.3.0.dist-info → flowyml-1.5.0.dist-info}/entry_points.txt +0 -0
- {flowyml-1.3.0.dist-info → flowyml-1.5.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: [] };
|
|
@@ -159,8 +159,10 @@ export function PipelineGraph({ dag, steps, selectedStep, onStepSelect }) {
|
|
|
159
159
|
const onNodeClick = useCallback((event, node) => {
|
|
160
160
|
if (node.type === 'step' && onStepSelect) {
|
|
161
161
|
onStepSelect(node.id);
|
|
162
|
+
} else if (node.type === 'artifact' && onArtifactSelect) {
|
|
163
|
+
onArtifactSelect(node.data.label);
|
|
162
164
|
}
|
|
163
|
-
}, [onStepSelect]);
|
|
165
|
+
}, [onStepSelect, onArtifactSelect]);
|
|
164
166
|
|
|
165
167
|
const nodeTypes = useMemo(() => ({
|
|
166
168
|
step: CustomStepNode,
|
|
@@ -168,7 +170,7 @@ export function PipelineGraph({ dag, steps, selectedStep, onStepSelect }) {
|
|
|
168
170
|
}), []);
|
|
169
171
|
|
|
170
172
|
return (
|
|
171
|
-
<div className="w-full h-full bg-slate-50/50 rounded-xl border border-slate-200 overflow-hidden">
|
|
173
|
+
<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
174
|
<ReactFlow
|
|
173
175
|
nodes={nodes}
|
|
174
176
|
edges={edges}
|
|
@@ -199,35 +201,35 @@ function CustomStepNode({ data }) {
|
|
|
199
201
|
const statusConfig = {
|
|
200
202
|
success: {
|
|
201
203
|
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'
|
|
204
|
+
color: 'text-emerald-600 dark:text-emerald-400',
|
|
205
|
+
bg: 'bg-white dark:bg-slate-800',
|
|
206
|
+
border: 'border-emerald-500 dark:border-emerald-500',
|
|
207
|
+
ring: 'ring-emerald-200 dark:ring-emerald-900',
|
|
208
|
+
shadow: 'shadow-emerald-100 dark:shadow-none'
|
|
207
209
|
},
|
|
208
210
|
failed: {
|
|
209
211
|
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'
|
|
212
|
+
color: 'text-rose-600 dark:text-rose-400',
|
|
213
|
+
bg: 'bg-white dark:bg-slate-800',
|
|
214
|
+
border: 'border-rose-500 dark:border-rose-500',
|
|
215
|
+
ring: 'ring-rose-200 dark:ring-rose-900',
|
|
216
|
+
shadow: 'shadow-rose-100 dark:shadow-none'
|
|
215
217
|
},
|
|
216
218
|
running: {
|
|
217
219
|
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'
|
|
220
|
+
color: 'text-amber-600 dark:text-amber-400',
|
|
221
|
+
bg: 'bg-white dark:bg-slate-800',
|
|
222
|
+
border: 'border-amber-500 dark:border-amber-500',
|
|
223
|
+
ring: 'ring-amber-200 dark:ring-amber-900',
|
|
224
|
+
shadow: 'shadow-amber-100 dark:shadow-none'
|
|
223
225
|
},
|
|
224
226
|
pending: {
|
|
225
227
|
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'
|
|
228
|
+
color: 'text-slate-400 dark:text-slate-500',
|
|
229
|
+
bg: 'bg-slate-50 dark:bg-slate-800/50',
|
|
230
|
+
border: 'border-slate-300 dark:border-slate-700',
|
|
231
|
+
ring: 'ring-slate-200 dark:ring-slate-800',
|
|
232
|
+
shadow: 'shadow-slate-100 dark:shadow-none'
|
|
231
233
|
}
|
|
232
234
|
};
|
|
233
235
|
|
|
@@ -343,7 +345,7 @@ function CustomArtifactNode({ data }) {
|
|
|
343
345
|
|
|
344
346
|
return (
|
|
345
347
|
<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]`}
|
|
348
|
+
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
349
|
style={{ height: artifactNodeHeight }}
|
|
348
350
|
>
|
|
349
351
|
<Handle type="target" position={Position.Top} className="!bg-slate-400 !w-2 !h-2" />
|
|
@@ -27,6 +27,7 @@ import { ProjectSelector } from './ProjectSelector';
|
|
|
27
27
|
|
|
28
28
|
export function RunDetailsPanel({ run, onClose }) {
|
|
29
29
|
const [details, setDetails] = useState(null);
|
|
30
|
+
const [artifacts, setArtifacts] = useState([]);
|
|
30
31
|
const [loading, setLoading] = useState(false);
|
|
31
32
|
const [activeTab, setActiveTab] = useState('overview'); // overview, steps, artifacts
|
|
32
33
|
const [currentProject, setCurrentProject] = useState(run?.project);
|
|
@@ -41,9 +42,17 @@ export function RunDetailsPanel({ run, onClose }) {
|
|
|
41
42
|
const fetchRunDetails = async () => {
|
|
42
43
|
setLoading(true);
|
|
43
44
|
try {
|
|
44
|
-
const
|
|
45
|
-
|
|
45
|
+
const [runRes, assetsRes] = await Promise.all([
|
|
46
|
+
fetchApi(`/api/runs/${run.run_id}`),
|
|
47
|
+
fetchApi(`/api/assets?run_id=${run.run_id}`)
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const data = await runRes.json();
|
|
51
|
+
const assetsData = await assetsRes.json();
|
|
52
|
+
|
|
46
53
|
setDetails(data);
|
|
54
|
+
setArtifacts(assetsData.assets || []);
|
|
55
|
+
|
|
47
56
|
if (data.project) setCurrentProject(data.project);
|
|
48
57
|
} catch (error) {
|
|
49
58
|
console.error('Failed to fetch run details:', error);
|
|
@@ -177,11 +186,11 @@ export function RunDetailsPanel({ run, onClose }) {
|
|
|
177
186
|
{activeTab === 'overview' && (
|
|
178
187
|
<div className="space-y-4">
|
|
179
188
|
{/* DAG Visualization Preview */}
|
|
180
|
-
<
|
|
181
|
-
<div className="p-3 border-b border-slate-100 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/50 flex justify-between items-center">
|
|
189
|
+
<div className="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden h-[400px] flex flex-col bg-white dark:bg-slate-800">
|
|
190
|
+
<div className="p-3 border-b border-slate-100 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/50 flex justify-between items-center shrink-0">
|
|
182
191
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Pipeline Graph</h3>
|
|
183
192
|
</div>
|
|
184
|
-
<div className="h-
|
|
193
|
+
<div className="flex-1 min-h-0 bg-slate-50/50 dark:bg-slate-900/50">
|
|
185
194
|
{runData.dag ? (
|
|
186
195
|
<PipelineGraph
|
|
187
196
|
dag={runData.dag}
|
|
@@ -193,7 +202,7 @@ export function RunDetailsPanel({ run, onClose }) {
|
|
|
193
202
|
</div>
|
|
194
203
|
)}
|
|
195
204
|
</div>
|
|
196
|
-
</
|
|
205
|
+
</div>
|
|
197
206
|
|
|
198
207
|
{/* Error Display if Failed */}
|
|
199
208
|
{runData.status === 'failed' && runData.error && (
|
|
@@ -238,14 +247,33 @@ export function RunDetailsPanel({ run, onClose }) {
|
|
|
238
247
|
|
|
239
248
|
{activeTab === 'artifacts' && (
|
|
240
249
|
<div className="space-y-3">
|
|
241
|
-
{
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
250
|
+
{artifacts.length > 0 ? (
|
|
251
|
+
artifacts.map(art => (
|
|
252
|
+
<div
|
|
253
|
+
key={art.artifact_id}
|
|
254
|
+
className="flex items-center gap-3 p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
|
|
255
|
+
>
|
|
256
|
+
<div className="p-2 bg-slate-50 dark:bg-slate-700 rounded-md text-slate-500 dark:text-slate-400">
|
|
257
|
+
<FileText size={18} />
|
|
258
|
+
</div>
|
|
259
|
+
<div className="min-w-0 flex-1">
|
|
260
|
+
<p className="text-sm font-semibold text-slate-900 dark:text-white truncate">
|
|
261
|
+
{art.name}
|
|
262
|
+
</p>
|
|
263
|
+
<p className="text-xs text-slate-500 truncate">{art.type}</p>
|
|
264
|
+
</div>
|
|
265
|
+
<Link to={`/runs/${run.run_id}`}>
|
|
266
|
+
<Button variant="ghost" size="sm">
|
|
267
|
+
<ArrowRight size={14} />
|
|
268
|
+
</Button>
|
|
269
|
+
</Link>
|
|
270
|
+
</div>
|
|
271
|
+
))
|
|
272
|
+
) : (
|
|
273
|
+
<div className="text-center py-8 text-slate-500">
|
|
274
|
+
No artifacts found for this run.
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
249
277
|
</div>
|
|
250
278
|
)}
|
|
251
279
|
</>
|
|
@@ -16,6 +16,8 @@ import { Leaderboard } from '../app/leaderboard/page';
|
|
|
16
16
|
import { Plugins } from '../app/plugins/page';
|
|
17
17
|
import { Settings } from '../app/settings/page';
|
|
18
18
|
import { TokenManagement } from '../app/tokens/page';
|
|
19
|
+
import { RunComparisonPage } from '../app/compare/page';
|
|
20
|
+
import { ExperimentComparisonPage } from '../app/experiments/compare/page';
|
|
19
21
|
|
|
20
22
|
export const router = createBrowserRouter([
|
|
21
23
|
{
|
|
@@ -25,9 +27,11 @@ export const router = createBrowserRouter([
|
|
|
25
27
|
{ index: true, element: <Dashboard /> },
|
|
26
28
|
{ path: 'pipelines', element: <Pipelines /> },
|
|
27
29
|
{ path: 'runs', element: <Runs /> },
|
|
30
|
+
{ path: 'compare', element: <RunComparisonPage /> },
|
|
28
31
|
{ path: 'runs/:runId', element: <RunDetails /> },
|
|
29
32
|
{ path: 'assets', element: <Assets /> },
|
|
30
33
|
{ path: 'experiments', element: <Experiments /> },
|
|
34
|
+
{ path: 'experiments/compare', element: <ExperimentComparisonPage /> },
|
|
31
35
|
{ path: 'experiments/:experimentId', element: <ExperimentDetails /> },
|
|
32
36
|
{ path: 'traces', element: <Traces /> },
|
|
33
37
|
{ path: 'projects', element: <Projects /> },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowyml
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Next-Generation ML Pipeline Framework
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -36,11 +36,13 @@ Requires-Dist: httpx (>=0.24,<0.28)
|
|
|
36
36
|
Requires-Dist: loguru (>=0.7.3,<0.8.0)
|
|
37
37
|
Requires-Dist: numpy (>=1.20.0)
|
|
38
38
|
Requires-Dist: pandas (>=1.3.0)
|
|
39
|
+
Requires-Dist: psycopg2-binary (>=2.9.0)
|
|
39
40
|
Requires-Dist: pydantic (>=2.0.0)
|
|
40
41
|
Requires-Dist: python-multipart (>=0.0.6) ; extra == "ui" or extra == "all"
|
|
41
42
|
Requires-Dist: pytz (>=2024.1,<2025.0)
|
|
42
43
|
Requires-Dist: pyyaml (>=6.0)
|
|
43
44
|
Requires-Dist: scikit-learn (>=1.0.0) ; extra == "sklearn" or extra == "all"
|
|
45
|
+
Requires-Dist: sqlalchemy (>=2.0.0)
|
|
44
46
|
Requires-Dist: tensorflow (>=2.12.0) ; extra == "tensorflow" or extra == "all"
|
|
45
47
|
Requires-Dist: toml (>=0.10.2)
|
|
46
48
|
Requires-Dist: torch (>=2.0.0) ; extra == "pytorch" or extra == "all"
|