flowyml 1.2.0__py3-none-any.whl → 1.3.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 +3 -0
- flowyml/assets/base.py +10 -0
- flowyml/assets/metrics.py +6 -0
- flowyml/cli/main.py +108 -2
- flowyml/cli/run.py +9 -2
- flowyml/core/execution_status.py +52 -0
- flowyml/core/hooks.py +106 -0
- flowyml/core/observability.py +210 -0
- flowyml/core/orchestrator.py +274 -0
- flowyml/core/pipeline.py +193 -231
- flowyml/core/project.py +34 -2
- flowyml/core/remote_orchestrator.py +109 -0
- flowyml/core/resources.py +22 -5
- flowyml/core/retry_policy.py +80 -0
- flowyml/core/step.py +18 -1
- flowyml/core/submission_result.py +53 -0
- flowyml/integrations/keras.py +95 -22
- flowyml/monitoring/alerts.py +2 -2
- flowyml/stacks/__init__.py +15 -0
- flowyml/stacks/aws.py +599 -0
- flowyml/stacks/azure.py +295 -0
- flowyml/stacks/components.py +24 -2
- flowyml/stacks/gcp.py +158 -11
- flowyml/stacks/local.py +5 -0
- flowyml/storage/artifacts.py +15 -5
- flowyml/storage/materializers/__init__.py +2 -0
- flowyml/storage/materializers/cloudpickle.py +74 -0
- flowyml/storage/metadata.py +166 -5
- flowyml/ui/backend/main.py +41 -1
- flowyml/ui/backend/routers/assets.py +356 -15
- flowyml/ui/backend/routers/client.py +46 -0
- flowyml/ui/backend/routers/execution.py +13 -2
- flowyml/ui/backend/routers/experiments.py +48 -12
- flowyml/ui/backend/routers/metrics.py +213 -0
- flowyml/ui/backend/routers/pipelines.py +63 -7
- flowyml/ui/backend/routers/projects.py +33 -7
- flowyml/ui/backend/routers/runs.py +150 -8
- flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
- flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/src/App.jsx +4 -1
- flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
- flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
- flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
- flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
- flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
- flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
- flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
- flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
- flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
- flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
- flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
- flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
- flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
- flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
- flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
- flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
- flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
- flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
- flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
- flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
- flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
- flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
- flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
- flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
- flowyml/ui/frontend/src/router/index.jsx +4 -0
- flowyml/ui/frontend/src/utils/date.js +10 -0
- flowyml/ui/frontend/src/utils/downloads.js +11 -0
- flowyml/utils/config.py +6 -0
- flowyml/utils/stack_config.py +45 -3
- {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/METADATA +42 -4
- {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/RECORD +89 -52
- {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/licenses/LICENSE +1 -1
- flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
- flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
- {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/WHEEL +0 -0
- {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { fetchApi } from '../utils/api';
|
|
3
|
+
import {
|
|
4
|
+
FlaskConical,
|
|
5
|
+
Calendar,
|
|
6
|
+
Activity,
|
|
7
|
+
Clock,
|
|
8
|
+
CheckCircle,
|
|
9
|
+
XCircle,
|
|
10
|
+
PlayCircle,
|
|
11
|
+
ArrowRight,
|
|
12
|
+
MoreHorizontal,
|
|
13
|
+
Download,
|
|
14
|
+
Trash2,
|
|
15
|
+
X
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
import { Card } from './ui/Card';
|
|
18
|
+
import { Badge } from './ui/Badge';
|
|
19
|
+
import { Button } from './ui/Button';
|
|
20
|
+
import { format } from 'date-fns';
|
|
21
|
+
import { Link } from 'react-router-dom';
|
|
22
|
+
import { motion } from 'framer-motion';
|
|
23
|
+
import { StatusBadge } from './ui/ExecutionStatus';
|
|
24
|
+
|
|
25
|
+
export function ExperimentDetailsPanel({ experiment, onClose }) {
|
|
26
|
+
const [runs, setRuns] = useState([]);
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (experiment) {
|
|
31
|
+
fetchRuns();
|
|
32
|
+
}
|
|
33
|
+
}, [experiment]);
|
|
34
|
+
|
|
35
|
+
const fetchRuns = async () => {
|
|
36
|
+
setLoading(true);
|
|
37
|
+
try {
|
|
38
|
+
// Assuming we can filter runs by experiment name or ID
|
|
39
|
+
// If the API doesn't support filtering by experiment directly, we might need to fetch all and filter
|
|
40
|
+
// But let's assume there's a way or we use the pipeline name if available
|
|
41
|
+
const url = experiment.pipeline_name
|
|
42
|
+
? `/api/runs?pipeline=${encodeURIComponent(experiment.pipeline_name)}&limit=50`
|
|
43
|
+
: `/api/runs?limit=50`; // Fallback
|
|
44
|
+
|
|
45
|
+
const res = await fetchApi(url);
|
|
46
|
+
const data = await res.json();
|
|
47
|
+
|
|
48
|
+
// Filter runs that belong to this experiment if possible
|
|
49
|
+
// This depends on how experiments are linked to runs in the backend
|
|
50
|
+
// For now, let's assume runs with the same pipeline name are relevant
|
|
51
|
+
const relevantRuns = data.runs.filter(r =>
|
|
52
|
+
r.pipeline_name === experiment.pipeline_name ||
|
|
53
|
+
(experiment.runs && experiment.runs.includes(r.run_id))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
setRuns(relevantRuns);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Failed to fetch runs:', error);
|
|
59
|
+
} finally {
|
|
60
|
+
setLoading(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (!experiment) return null;
|
|
65
|
+
|
|
66
|
+
const stats = {
|
|
67
|
+
total: runs.length,
|
|
68
|
+
success: runs.filter(r => r.status === 'completed').length,
|
|
69
|
+
failed: runs.filter(r => r.status === 'failed').length,
|
|
70
|
+
avgDuration: runs.length > 0
|
|
71
|
+
? runs.reduce((acc, r) => acc + (r.duration || 0), 0) / runs.length
|
|
72
|
+
: 0
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="h-full flex flex-col bg-white dark:bg-slate-900">
|
|
77
|
+
{/* Header */}
|
|
78
|
+
<div className="p-6 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/50">
|
|
79
|
+
<div className="flex items-start justify-between mb-4">
|
|
80
|
+
<div className="flex items-center gap-4">
|
|
81
|
+
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-xl text-purple-600 dark:text-purple-400">
|
|
82
|
+
<FlaskConical size={24} />
|
|
83
|
+
</div>
|
|
84
|
+
<div>
|
|
85
|
+
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
|
86
|
+
{experiment.name}
|
|
87
|
+
</h2>
|
|
88
|
+
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
|
|
89
|
+
{experiment.project && (
|
|
90
|
+
<span className="flex items-center gap-1">
|
|
91
|
+
<span className="opacity-50">Project:</span>
|
|
92
|
+
<span className="font-medium text-slate-700 dark:text-slate-300">{experiment.project}</span>
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
<span>•</span>
|
|
96
|
+
<span>{format(new Date(experiment.created_at || Date.now()), 'MMM d, yyyy')}</span>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div className="flex items-center gap-2">
|
|
101
|
+
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
102
|
+
<X size={20} className="text-slate-400" />
|
|
103
|
+
</Button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Description */}
|
|
108
|
+
{experiment.description && (
|
|
109
|
+
<p className="text-slate-600 dark:text-slate-400 text-sm mb-6 max-w-3xl">
|
|
110
|
+
{experiment.description}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{/* Stats Grid */}
|
|
115
|
+
<div className="grid grid-cols-4 gap-4">
|
|
116
|
+
<StatCard
|
|
117
|
+
label="Total Runs"
|
|
118
|
+
value={stats.total}
|
|
119
|
+
icon={Activity}
|
|
120
|
+
color="blue"
|
|
121
|
+
/>
|
|
122
|
+
<StatCard
|
|
123
|
+
label="Success Rate"
|
|
124
|
+
value={`${stats.total > 0 ? Math.round((stats.success / stats.total) * 100) : 0}%`}
|
|
125
|
+
icon={CheckCircle}
|
|
126
|
+
color="emerald"
|
|
127
|
+
/>
|
|
128
|
+
<StatCard
|
|
129
|
+
label="Avg Duration"
|
|
130
|
+
value={`${stats.avgDuration.toFixed(1)}s`}
|
|
131
|
+
icon={Clock}
|
|
132
|
+
color="purple"
|
|
133
|
+
/>
|
|
134
|
+
<StatCard
|
|
135
|
+
label="Failed"
|
|
136
|
+
value={stats.failed}
|
|
137
|
+
icon={XCircle}
|
|
138
|
+
color="rose"
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Content - Runs List */}
|
|
144
|
+
<div className="flex-1 overflow-hidden flex flex-col">
|
|
145
|
+
<div className="p-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900">
|
|
146
|
+
<h3 className="font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
|
147
|
+
<PlayCircle size={18} className="text-slate-400" />
|
|
148
|
+
Recent Runs
|
|
149
|
+
</h3>
|
|
150
|
+
<Link to={`/runs?pipeline=${encodeURIComponent(experiment.pipeline_name || '')}`}>
|
|
151
|
+
<Button variant="ghost" size="sm" className="text-primary-600">
|
|
152
|
+
View All Runs <ArrowRight size={16} className="ml-1" />
|
|
153
|
+
</Button>
|
|
154
|
+
</Link>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50 dark:bg-slate-900/50">
|
|
158
|
+
{loading ? (
|
|
159
|
+
<div className="flex justify-center py-8">
|
|
160
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
161
|
+
</div>
|
|
162
|
+
) : runs.length === 0 ? (
|
|
163
|
+
<div className="text-center py-12 text-slate-500">
|
|
164
|
+
No runs found for this experiment
|
|
165
|
+
</div>
|
|
166
|
+
) : (
|
|
167
|
+
runs.map(run => (
|
|
168
|
+
<Link key={run.run_id} to={`/runs/${run.run_id}`}>
|
|
169
|
+
<motion.div
|
|
170
|
+
initial={{ opacity: 0, y: 10 }}
|
|
171
|
+
animate={{ opacity: 1, y: 0 }}
|
|
172
|
+
className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700 hover:shadow-md hover:border-primary-300 dark:hover:border-primary-700 transition-all group"
|
|
173
|
+
>
|
|
174
|
+
<div className="flex items-center justify-between">
|
|
175
|
+
<div className="flex items-center gap-4">
|
|
176
|
+
<StatusBadge status={run.status} />
|
|
177
|
+
<div>
|
|
178
|
+
<div className="font-medium text-slate-900 dark:text-white flex items-center gap-2">
|
|
179
|
+
{run.name || `Run ${run.run_id.slice(0, 8)}`}
|
|
180
|
+
<span className="text-xs font-mono text-slate-400">#{run.run_id.slice(0, 6)}</span>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="flex items-center gap-3 text-xs text-slate-500 mt-1">
|
|
183
|
+
<span className="flex items-center gap-1">
|
|
184
|
+
<Calendar size={12} />
|
|
185
|
+
{format(new Date(run.created || run.start_time), 'MMM d, HH:mm')}
|
|
186
|
+
</span>
|
|
187
|
+
<span className="flex items-center gap-1">
|
|
188
|
+
<Clock size={12} />
|
|
189
|
+
{run.duration ? `${run.duration.toFixed(1)}s` : '-'}
|
|
190
|
+
</span>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<ArrowRight size={16} className="text-slate-300 group-hover:text-primary-500 transition-colors" />
|
|
195
|
+
</div>
|
|
196
|
+
</motion.div>
|
|
197
|
+
</Link>
|
|
198
|
+
))
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function StatCard({ label, value, icon: Icon, color }) {
|
|
207
|
+
const colorClasses = {
|
|
208
|
+
blue: 'text-blue-600 bg-blue-50 dark:bg-blue-900/20',
|
|
209
|
+
emerald: 'text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20',
|
|
210
|
+
purple: 'text-purple-600 bg-purple-50 dark:bg-purple-900/20',
|
|
211
|
+
rose: 'text-rose-600 bg-rose-50 dark:bg-rose-900/20',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className="bg-white dark:bg-slate-800 p-3 rounded-xl border border-slate-200 dark:border-slate-700">
|
|
216
|
+
<div className="flex items-center gap-2 mb-1">
|
|
217
|
+
<div className={`p-1 rounded-lg ${colorClasses[color]}`}>
|
|
218
|
+
<Icon size={14} />
|
|
219
|
+
</div>
|
|
220
|
+
<span className="text-xs text-slate-500 font-medium">{label}</span>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="text-lg font-bold text-slate-900 dark:text-white pl-1">
|
|
223
|
+
{value}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { fetchApi } from '../utils/api';
|
|
3
|
+
import {
|
|
4
|
+
ChevronRight,
|
|
5
|
+
ChevronDown,
|
|
6
|
+
Box,
|
|
7
|
+
Activity,
|
|
8
|
+
PlayCircle,
|
|
9
|
+
FileBox,
|
|
10
|
+
CheckCircle,
|
|
11
|
+
XCircle,
|
|
12
|
+
Clock,
|
|
13
|
+
Database,
|
|
14
|
+
Layers,
|
|
15
|
+
X,
|
|
16
|
+
Download,
|
|
17
|
+
Info,
|
|
18
|
+
BarChart2,
|
|
19
|
+
FileText,
|
|
20
|
+
Eye,
|
|
21
|
+
Folder,
|
|
22
|
+
GitBranch,
|
|
23
|
+
FlaskConical,
|
|
24
|
+
Search,
|
|
25
|
+
Filter
|
|
26
|
+
} from 'lucide-react';
|
|
27
|
+
import { Link } from 'react-router-dom';
|
|
28
|
+
import { formatDate } from '../utils/date';
|
|
29
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
30
|
+
|
|
31
|
+
const StatusIcon = ({ status }) => {
|
|
32
|
+
switch (status?.toLowerCase()) {
|
|
33
|
+
case 'completed':
|
|
34
|
+
case 'success':
|
|
35
|
+
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />;
|
|
36
|
+
case 'failed':
|
|
37
|
+
return <XCircle className="w-3.5 h-3.5 text-rose-500" />;
|
|
38
|
+
case 'running':
|
|
39
|
+
return <Activity className="w-3.5 h-3.5 text-amber-500 animate-spin" />;
|
|
40
|
+
default:
|
|
41
|
+
return <Clock className="w-3.5 h-3.5 text-slate-400" />;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const TreeNode = ({
|
|
46
|
+
label,
|
|
47
|
+
icon: Icon,
|
|
48
|
+
children,
|
|
49
|
+
defaultExpanded = false,
|
|
50
|
+
actions,
|
|
51
|
+
status,
|
|
52
|
+
level = 0,
|
|
53
|
+
badge,
|
|
54
|
+
onClick,
|
|
55
|
+
isActive = false,
|
|
56
|
+
subLabel
|
|
57
|
+
}) => {
|
|
58
|
+
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
|
59
|
+
const hasChildren = children && children.length > 0;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="select-none">
|
|
63
|
+
<motion.div
|
|
64
|
+
initial={false}
|
|
65
|
+
animate={{ backgroundColor: isActive ? 'rgba(59, 130, 246, 0.1)' : 'transparent' }}
|
|
66
|
+
className={`
|
|
67
|
+
group flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer transition-all
|
|
68
|
+
hover:bg-slate-100 dark:hover:bg-slate-800
|
|
69
|
+
${isActive ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-slate-700 dark:text-slate-300'}
|
|
70
|
+
${level === 0 ? 'mb-0.5' : ''}
|
|
71
|
+
`}
|
|
72
|
+
style={{ paddingLeft: `${level * 1.25 + 0.5}rem` }}
|
|
73
|
+
onClick={(e) => {
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
if (hasChildren) {
|
|
76
|
+
setIsExpanded(!isExpanded);
|
|
77
|
+
}
|
|
78
|
+
onClick?.();
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<div className="flex items-center gap-1 text-slate-400 shrink-0">
|
|
82
|
+
{hasChildren ? (
|
|
83
|
+
<motion.div
|
|
84
|
+
animate={{ rotate: isExpanded ? 90 : 0 }}
|
|
85
|
+
transition={{ duration: 0.2 }}
|
|
86
|
+
>
|
|
87
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
88
|
+
</motion.div>
|
|
89
|
+
) : (
|
|
90
|
+
<div className="w-3.5" />
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{Icon && (
|
|
95
|
+
<Icon className={`w-4 h-4 shrink-0 ${isActive ? 'text-blue-500' : 'text-slate-400 group-hover:text-slate-500 dark:text-slate-500 dark:group-hover:text-slate-400'}`} />
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
<div className="flex-1 flex items-center justify-between gap-2 min-w-0 overflow-hidden">
|
|
99
|
+
<div className="flex flex-col min-w-0">
|
|
100
|
+
<span className={`text-sm truncate ${isActive ? 'font-medium' : ''}`}>
|
|
101
|
+
{label}
|
|
102
|
+
</span>
|
|
103
|
+
{subLabel && (
|
|
104
|
+
<span className="text-[10px] text-slate-400 truncate">
|
|
105
|
+
{subLabel}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
110
|
+
{badge}
|
|
111
|
+
{status && <StatusIcon status={status} />}
|
|
112
|
+
{actions}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</motion.div>
|
|
116
|
+
|
|
117
|
+
<AnimatePresence initial={false}>
|
|
118
|
+
{isExpanded && hasChildren && (
|
|
119
|
+
<motion.div
|
|
120
|
+
initial={{ opacity: 0, height: 0 }}
|
|
121
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
122
|
+
exit={{ opacity: 0, height: 0 }}
|
|
123
|
+
transition={{ duration: 0.2 }}
|
|
124
|
+
>
|
|
125
|
+
{children}
|
|
126
|
+
</motion.div>
|
|
127
|
+
)}
|
|
128
|
+
</AnimatePresence>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function NavigationTree({
|
|
134
|
+
projectId,
|
|
135
|
+
onSelect,
|
|
136
|
+
selectedId,
|
|
137
|
+
mode = 'experiments', // experiments, pipelines, runs
|
|
138
|
+
className = ''
|
|
139
|
+
}) {
|
|
140
|
+
const [data, setData] = useState({ projects: [], items: [] });
|
|
141
|
+
const [loading, setLoading] = useState(true);
|
|
142
|
+
const [error, setError] = useState(null);
|
|
143
|
+
const [filter, setFilter] = useState('');
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const fetchData = async () => {
|
|
147
|
+
setLoading(true);
|
|
148
|
+
setError(null);
|
|
149
|
+
try {
|
|
150
|
+
let url = '';
|
|
151
|
+
let itemsKey = '';
|
|
152
|
+
|
|
153
|
+
switch (mode) {
|
|
154
|
+
case 'experiments':
|
|
155
|
+
url = projectId
|
|
156
|
+
? `/api/experiments/?project=${encodeURIComponent(projectId)}`
|
|
157
|
+
: '/api/experiments/';
|
|
158
|
+
itemsKey = 'experiments';
|
|
159
|
+
break;
|
|
160
|
+
case 'pipelines':
|
|
161
|
+
url = projectId
|
|
162
|
+
? `/api/pipelines/?project=${encodeURIComponent(projectId)}`
|
|
163
|
+
: '/api/pipelines/';
|
|
164
|
+
itemsKey = 'pipelines';
|
|
165
|
+
break;
|
|
166
|
+
case 'runs':
|
|
167
|
+
url = projectId
|
|
168
|
+
? `/api/runs/?project=${encodeURIComponent(projectId)}&limit=100`
|
|
169
|
+
: '/api/runs/?limit=100';
|
|
170
|
+
itemsKey = 'runs';
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const res = await fetchApi(url);
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
throw new Error(`Failed to fetch ${mode}: ${res.statusText}`);
|
|
179
|
+
}
|
|
180
|
+
const jsonData = await res.json();
|
|
181
|
+
|
|
182
|
+
// If no project selected, we might need to fetch projects to group by
|
|
183
|
+
let projects = [];
|
|
184
|
+
if (!projectId) {
|
|
185
|
+
// Extract unique projects from items
|
|
186
|
+
const items = jsonData[itemsKey] || [];
|
|
187
|
+
const projectNames = [...new Set(items.map(i => i.project).filter(Boolean))];
|
|
188
|
+
projects = projectNames.map(name => ({ name }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
setData({
|
|
192
|
+
projects,
|
|
193
|
+
items: jsonData[itemsKey] || []
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error('Failed to fetch navigation data:', error);
|
|
197
|
+
setError(error.message);
|
|
198
|
+
} finally {
|
|
199
|
+
setLoading(false);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
fetchData();
|
|
204
|
+
}, [projectId, mode]);
|
|
205
|
+
|
|
206
|
+
if (loading) {
|
|
207
|
+
return (
|
|
208
|
+
<div className={`p-4 ${className}`}>
|
|
209
|
+
<div className="animate-pulse space-y-3">
|
|
210
|
+
{[1, 2, 3, 4, 5].map(i => (
|
|
211
|
+
<div key={i} className="h-8 bg-slate-100 dark:bg-slate-800 rounded-lg"></div>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (error) {
|
|
219
|
+
return (
|
|
220
|
+
<div className={`p-4 text-center ${className}`}>
|
|
221
|
+
<div className="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg border border-red-100 dark:border-red-800">
|
|
222
|
+
<XCircle className="w-8 h-8 text-red-500 mx-auto mb-2" />
|
|
223
|
+
<p className="text-sm text-red-600 dark:text-red-400 font-medium">Failed to load data</p>
|
|
224
|
+
<p className="text-xs text-red-500 dark:text-red-500/80 mt-1">{error}</p>
|
|
225
|
+
<button
|
|
226
|
+
onClick={() => window.location.reload()}
|
|
227
|
+
className="mt-3 text-xs bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-3 py-1.5 rounded-md hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
|
228
|
+
>
|
|
229
|
+
Retry
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const filteredItems = data.items.filter(item =>
|
|
237
|
+
(item.name || item.run_id || '').toLowerCase().includes(filter.toLowerCase())
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
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
|
+
const renderExperimentNode = (exp, level) => (
|
|
249
|
+
<TreeNode
|
|
250
|
+
key={exp.experiment_id}
|
|
251
|
+
label={exp.name}
|
|
252
|
+
icon={FlaskConical}
|
|
253
|
+
level={level}
|
|
254
|
+
isActive={selectedId === exp.experiment_id}
|
|
255
|
+
onClick={() => onSelect?.(exp)}
|
|
256
|
+
badge={
|
|
257
|
+
<span className="text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-500 px-1.5 py-0.5 rounded-full">
|
|
258
|
+
{exp.run_count || 0}
|
|
259
|
+
</span>
|
|
260
|
+
}
|
|
261
|
+
/>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (projectId || data.projects.length === 0) {
|
|
265
|
+
return filteredItems.map(exp => renderExperimentNode(exp, 0));
|
|
266
|
+
} else {
|
|
267
|
+
return data.projects.map(proj => {
|
|
268
|
+
const projExperiments = filteredItems.filter(e => e.project === proj.name);
|
|
269
|
+
if (projExperiments.length === 0) return null;
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<TreeNode
|
|
273
|
+
key={proj.name}
|
|
274
|
+
label={proj.name}
|
|
275
|
+
icon={Folder}
|
|
276
|
+
level={0}
|
|
277
|
+
defaultExpanded={true}
|
|
278
|
+
badge={
|
|
279
|
+
<span className="text-xs text-slate-400">
|
|
280
|
+
{projExperiments.length}
|
|
281
|
+
</span>
|
|
282
|
+
}
|
|
283
|
+
>
|
|
284
|
+
{projExperiments.map(exp => renderExperimentNode(exp, 1))}
|
|
285
|
+
</TreeNode>
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const renderPipelines = () => {
|
|
292
|
+
const renderPipelineNode = (pipeline, level) => (
|
|
293
|
+
<TreeNode
|
|
294
|
+
key={pipeline.name}
|
|
295
|
+
label={pipeline.name}
|
|
296
|
+
icon={Layers}
|
|
297
|
+
level={level}
|
|
298
|
+
isActive={selectedId === pipeline.name}
|
|
299
|
+
onClick={() => onSelect?.(pipeline)}
|
|
300
|
+
badge={
|
|
301
|
+
<span className="text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-500 px-1.5 py-0.5 rounded-full">
|
|
302
|
+
v{pipeline.version || '1'}
|
|
303
|
+
</span>
|
|
304
|
+
}
|
|
305
|
+
/>
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (projectId || data.projects.length === 0) {
|
|
309
|
+
return filteredItems.map(p => renderPipelineNode(p, 0));
|
|
310
|
+
} else {
|
|
311
|
+
return data.projects.map(proj => {
|
|
312
|
+
const projPipelines = filteredItems.filter(p => p.project === proj.name);
|
|
313
|
+
if (projPipelines.length === 0) return null;
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<TreeNode
|
|
317
|
+
key={proj.name}
|
|
318
|
+
label={proj.name}
|
|
319
|
+
icon={Folder}
|
|
320
|
+
level={0}
|
|
321
|
+
defaultExpanded={true}
|
|
322
|
+
>
|
|
323
|
+
{projPipelines.map(p => renderPipelineNode(p, 1))}
|
|
324
|
+
</TreeNode>
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const renderRuns = () => {
|
|
331
|
+
const renderRunNode = (run, level) => (
|
|
332
|
+
<TreeNode
|
|
333
|
+
key={run.run_id}
|
|
334
|
+
label={run.name || run.run_id.slice(0, 8)}
|
|
335
|
+
subLabel={formatDate(run.created || run.start_time)}
|
|
336
|
+
icon={PlayCircle}
|
|
337
|
+
level={level}
|
|
338
|
+
status={run.status}
|
|
339
|
+
isActive={selectedId === run.run_id}
|
|
340
|
+
onClick={() => onSelect?.(run)}
|
|
341
|
+
/>
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// Group by Pipeline
|
|
345
|
+
const pipelines = [...new Set(filteredItems.map(r => r.pipeline_name).filter(Boolean))];
|
|
346
|
+
|
|
347
|
+
return pipelines.map(pipelineName => {
|
|
348
|
+
const pipelineRuns = filteredItems.filter(r => r.pipeline_name === pipelineName);
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<TreeNode
|
|
352
|
+
key={pipelineName}
|
|
353
|
+
label={pipelineName}
|
|
354
|
+
icon={Activity}
|
|
355
|
+
level={0}
|
|
356
|
+
defaultExpanded={true}
|
|
357
|
+
badge={
|
|
358
|
+
<span className="text-xs text-slate-400">
|
|
359
|
+
{pipelineRuns.length}
|
|
360
|
+
</span>
|
|
361
|
+
}
|
|
362
|
+
>
|
|
363
|
+
{pipelineRuns.map(run => renderRunNode(run, 1))}
|
|
364
|
+
</TreeNode>
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<div className={`flex flex-col h-full bg-slate-50/50 dark:bg-slate-900/50 ${className}`}>
|
|
371
|
+
{/* Search */}
|
|
372
|
+
<div className="p-2 sticky top-0 bg-inherit z-10">
|
|
373
|
+
<div className="relative">
|
|
374
|
+
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-slate-400" />
|
|
375
|
+
<input
|
|
376
|
+
type="text"
|
|
377
|
+
placeholder={`Search ${mode}...`}
|
|
378
|
+
value={filter}
|
|
379
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
380
|
+
className="w-full pl-9 pr-3 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
{/* Tree Content */}
|
|
386
|
+
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
|
387
|
+
{filteredItems.length === 0 ? (
|
|
388
|
+
<div className="text-center py-8 text-slate-400 text-sm">
|
|
389
|
+
No {mode} found
|
|
390
|
+
</div>
|
|
391
|
+
) : (
|
|
392
|
+
<>
|
|
393
|
+
{mode === 'experiments' && renderExperiments()}
|
|
394
|
+
{mode === 'pipelines' && renderPipelines()}
|
|
395
|
+
{mode === 'runs' && renderRuns()}
|
|
396
|
+
</>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|