flowyml 1.2.0__py3-none-any.whl → 1.4.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 (104) hide show
  1. flowyml/__init__.py +3 -0
  2. flowyml/assets/base.py +10 -0
  3. flowyml/assets/metrics.py +6 -0
  4. flowyml/cli/main.py +108 -2
  5. flowyml/cli/run.py +9 -2
  6. flowyml/core/execution_status.py +52 -0
  7. flowyml/core/hooks.py +106 -0
  8. flowyml/core/observability.py +210 -0
  9. flowyml/core/orchestrator.py +274 -0
  10. flowyml/core/pipeline.py +193 -231
  11. flowyml/core/project.py +34 -2
  12. flowyml/core/remote_orchestrator.py +109 -0
  13. flowyml/core/resources.py +34 -17
  14. flowyml/core/retry_policy.py +80 -0
  15. flowyml/core/scheduler.py +9 -9
  16. flowyml/core/scheduler_config.py +2 -3
  17. flowyml/core/step.py +18 -1
  18. flowyml/core/submission_result.py +53 -0
  19. flowyml/integrations/keras.py +95 -22
  20. flowyml/monitoring/alerts.py +2 -2
  21. flowyml/stacks/__init__.py +15 -0
  22. flowyml/stacks/aws.py +599 -0
  23. flowyml/stacks/azure.py +295 -0
  24. flowyml/stacks/bridge.py +9 -9
  25. flowyml/stacks/components.py +24 -2
  26. flowyml/stacks/gcp.py +158 -11
  27. flowyml/stacks/local.py +5 -0
  28. flowyml/stacks/plugins.py +2 -2
  29. flowyml/stacks/registry.py +21 -0
  30. flowyml/storage/artifacts.py +15 -5
  31. flowyml/storage/materializers/__init__.py +2 -0
  32. flowyml/storage/materializers/base.py +33 -0
  33. flowyml/storage/materializers/cloudpickle.py +74 -0
  34. flowyml/storage/metadata.py +3 -881
  35. flowyml/storage/remote.py +590 -0
  36. flowyml/storage/sql.py +911 -0
  37. flowyml/ui/backend/dependencies.py +28 -0
  38. flowyml/ui/backend/main.py +43 -80
  39. flowyml/ui/backend/routers/assets.py +483 -17
  40. flowyml/ui/backend/routers/client.py +46 -0
  41. flowyml/ui/backend/routers/execution.py +13 -2
  42. flowyml/ui/backend/routers/experiments.py +97 -14
  43. flowyml/ui/backend/routers/metrics.py +168 -0
  44. flowyml/ui/backend/routers/pipelines.py +77 -12
  45. flowyml/ui/backend/routers/projects.py +33 -7
  46. flowyml/ui/backend/routers/runs.py +221 -12
  47. flowyml/ui/backend/routers/schedules.py +5 -21
  48. flowyml/ui/backend/routers/stats.py +14 -0
  49. flowyml/ui/backend/routers/traces.py +37 -53
  50. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
  51. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
  52. flowyml/ui/frontend/dist/index.html +2 -2
  53. flowyml/ui/frontend/src/App.jsx +4 -1
  54. flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
  55. flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
  56. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
  57. flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
  58. flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
  59. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
  60. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
  61. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
  62. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
  63. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
  64. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
  65. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
  66. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
  67. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
  68. flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
  69. flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
  70. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
  71. flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
  72. flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
  73. flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
  74. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
  75. flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
  76. flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
  77. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
  78. flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
  79. flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
  80. flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
  81. flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
  82. flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
  83. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
  84. flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
  85. flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
  86. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
  87. flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
  88. flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
  89. flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
  90. flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
  91. flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
  92. flowyml/ui/frontend/src/router/index.jsx +4 -0
  93. flowyml/ui/frontend/src/utils/date.js +10 -0
  94. flowyml/ui/frontend/src/utils/downloads.js +11 -0
  95. flowyml/utils/config.py +6 -0
  96. flowyml/utils/stack_config.py +45 -3
  97. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/METADATA +44 -4
  98. flowyml-1.4.0.dist-info/RECORD +200 -0
  99. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/licenses/LICENSE +1 -1
  100. flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
  101. flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
  102. flowyml-1.2.0.dist-info/RECORD +0 -159
  103. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/WHEEL +0 -0
  104. {flowyml-1.2.0.dist-info → flowyml-1.4.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
+ }