flowyml 1.7.0__py3-none-any.whl → 1.7.2__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/assets/dataset.py +570 -17
- flowyml/assets/model.py +1052 -15
- flowyml/core/executor.py +70 -11
- flowyml/core/orchestrator.py +37 -2
- flowyml/core/pipeline.py +32 -4
- flowyml/core/scheduler.py +88 -5
- flowyml/integrations/keras.py +247 -82
- flowyml/storage/sql.py +24 -6
- flowyml/ui/backend/routers/runs.py +112 -0
- flowyml/ui/backend/routers/schedules.py +35 -15
- flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +1 -0
- flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +685 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/package-lock.json +11 -0
- flowyml/ui/frontend/package.json +1 -0
- flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
- flowyml/ui/frontend/src/app/dashboard/page.jsx +1 -1
- flowyml/ui/frontend/src/app/experiments/[experimentId]/page.jsx +1 -1
- flowyml/ui/frontend/src/app/leaderboard/page.jsx +1 -1
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +3 -3
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +590 -102
- flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +401 -28
- flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
- flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
- flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
- flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/METADATA +1 -1
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/RECORD +33 -30
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
- flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +0 -630
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/WHEEL +0 -0
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/entry_points.txt +0 -0
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import React, { useMemo, useState, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
LineChart,
|
|
4
|
+
Line,
|
|
5
|
+
XAxis,
|
|
6
|
+
YAxis,
|
|
7
|
+
CartesianGrid,
|
|
8
|
+
Tooltip,
|
|
9
|
+
Legend,
|
|
10
|
+
ResponsiveContainer,
|
|
11
|
+
Area,
|
|
12
|
+
AreaChart,
|
|
13
|
+
Brush,
|
|
14
|
+
ReferenceLine,
|
|
15
|
+
} from 'recharts';
|
|
16
|
+
import { TrendingUp, TrendingDown, Target, Zap, Activity, Eye, EyeOff, ZoomIn, ZoomOut, Maximize2, BarChart3 } from 'lucide-react';
|
|
17
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
18
|
+
|
|
19
|
+
// Beautiful color palette for dynamic metric assignment
|
|
20
|
+
const METRIC_COLORS = [
|
|
21
|
+
{ main: '#3b82f6', light: '#93c5fd' }, // blue
|
|
22
|
+
{ main: '#8b5cf6', light: '#c4b5fd' }, // purple
|
|
23
|
+
{ main: '#10b981', light: '#6ee7b7' }, // emerald
|
|
24
|
+
{ main: '#f59e0b', light: '#fcd34d' }, // amber
|
|
25
|
+
{ main: '#ef4444', light: '#fca5a5' }, // red
|
|
26
|
+
{ main: '#ec4899', light: '#f9a8d4' }, // pink
|
|
27
|
+
{ main: '#06b6d4', light: '#67e8f9' }, // cyan
|
|
28
|
+
{ main: '#84cc16', light: '#bef264' }, // lime
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Custom tooltip with glassmorphism effect
|
|
32
|
+
const CustomTooltip = ({ active, payload, label }) => {
|
|
33
|
+
if (active && payload && payload.length) {
|
|
34
|
+
return (
|
|
35
|
+
<motion.div
|
|
36
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
37
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
38
|
+
className="bg-white/95 backdrop-blur-lg p-4 rounded-xl shadow-2xl border border-slate-200/50 max-w-xs"
|
|
39
|
+
>
|
|
40
|
+
<p className="font-bold text-slate-900 mb-2 flex items-center gap-2">
|
|
41
|
+
<Target size={14} className="text-primary-500" />
|
|
42
|
+
Epoch {label}
|
|
43
|
+
</p>
|
|
44
|
+
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
|
45
|
+
{payload.map((entry, index) => (
|
|
46
|
+
<div key={index} className="flex items-center justify-between gap-4">
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<div
|
|
49
|
+
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
50
|
+
style={{ backgroundColor: entry.color }}
|
|
51
|
+
/>
|
|
52
|
+
<span className="text-sm text-slate-600 truncate">{entry.name}</span>
|
|
53
|
+
</div>
|
|
54
|
+
<span className="text-sm font-mono font-bold text-slate-900">
|
|
55
|
+
{typeof entry.value === 'number' ? entry.value.toExponential(4) : entry.value}
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
</motion.div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Animated metric card
|
|
67
|
+
function MetricCard({ icon: Icon, label, value, trend, color, subValue }) {
|
|
68
|
+
const isPositive = trend === 'up';
|
|
69
|
+
const TrendIcon = isPositive ? TrendingUp : TrendingDown;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<motion.div
|
|
73
|
+
initial={{ opacity: 0, y: 20 }}
|
|
74
|
+
animate={{ opacity: 1, y: 0 }}
|
|
75
|
+
whileHover={{ scale: 1.02, y: -2 }}
|
|
76
|
+
className="relative overflow-hidden p-4 bg-gradient-to-br from-white to-slate-50 dark:from-slate-800 dark:to-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm hover:shadow-lg transition-all duration-300"
|
|
77
|
+
>
|
|
78
|
+
<div
|
|
79
|
+
className="absolute top-0 right-0 w-24 h-24 rounded-full blur-2xl opacity-20"
|
|
80
|
+
style={{ backgroundColor: color }}
|
|
81
|
+
/>
|
|
82
|
+
<div className="relative">
|
|
83
|
+
<div className="flex items-center gap-2 mb-2">
|
|
84
|
+
<div className="p-2 rounded-lg" style={{ backgroundColor: `${color}20` }}>
|
|
85
|
+
<Icon size={16} style={{ color }} />
|
|
86
|
+
</div>
|
|
87
|
+
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
|
88
|
+
{label}
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex items-end justify-between">
|
|
92
|
+
<span className="text-2xl font-bold text-slate-900 dark:text-white font-mono">
|
|
93
|
+
{typeof value === 'number' ? value.toFixed(4) : value}
|
|
94
|
+
</span>
|
|
95
|
+
{trend && (
|
|
96
|
+
<div className={`flex items-center gap-1 text-xs ${isPositive ? 'text-emerald-600' : 'text-rose-600'}`}>
|
|
97
|
+
<TrendIcon size={12} />
|
|
98
|
+
<span className="font-medium">{isPositive ? 'Improving' : 'Degrading'}</span>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
{subValue && (
|
|
103
|
+
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{subValue}</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
</motion.div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Scale selector component
|
|
111
|
+
function ScaleSelector({ scale, onChange }) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 rounded-lg p-1">
|
|
114
|
+
{['linear', 'log'].map((s) => (
|
|
115
|
+
<button
|
|
116
|
+
key={s}
|
|
117
|
+
onClick={() => onChange(s)}
|
|
118
|
+
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
|
|
119
|
+
scale === s
|
|
120
|
+
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm'
|
|
121
|
+
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
{s === 'log' ? 'Log' : 'Linear'}
|
|
125
|
+
</button>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Metric toggle button
|
|
132
|
+
function MetricToggle({ metric, color, visible, onToggle }) {
|
|
133
|
+
return (
|
|
134
|
+
<button
|
|
135
|
+
onClick={onToggle}
|
|
136
|
+
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
|
137
|
+
visible
|
|
138
|
+
? 'bg-white dark:bg-slate-700 shadow-sm border border-slate-200 dark:border-slate-600'
|
|
139
|
+
: 'bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500'
|
|
140
|
+
}`}
|
|
141
|
+
>
|
|
142
|
+
<div
|
|
143
|
+
className={`w-3 h-3 rounded-full transition-opacity ${visible ? 'opacity-100' : 'opacity-30'}`}
|
|
144
|
+
style={{ backgroundColor: color }}
|
|
145
|
+
/>
|
|
146
|
+
<span className={visible ? 'text-slate-700 dark:text-slate-200' : ''}>{metric}</span>
|
|
147
|
+
{visible ? <Eye size={12} /> : <EyeOff size={12} />}
|
|
148
|
+
</button>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Group metrics by type
|
|
153
|
+
function groupMetrics(metrics) {
|
|
154
|
+
const groups = {
|
|
155
|
+
loss: { train: [], val: [] },
|
|
156
|
+
accuracy: { train: [], val: [] },
|
|
157
|
+
other: { train: [], val: [] },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
metrics.forEach(metric => {
|
|
161
|
+
const isVal = metric.startsWith('val_');
|
|
162
|
+
const baseName = isVal ? metric.replace('val_', '') : metric.replace('train_', '');
|
|
163
|
+
const side = isVal ? 'val' : 'train';
|
|
164
|
+
|
|
165
|
+
if (baseName.includes('loss') || baseName.includes('mse') || baseName.includes('mae') || baseName.includes('error')) {
|
|
166
|
+
groups.loss[side].push(metric);
|
|
167
|
+
} else if (baseName.includes('accuracy') || baseName.includes('acc') || baseName.includes('f1') || baseName.includes('precision') || baseName.includes('recall')) {
|
|
168
|
+
groups.accuracy[side].push(metric);
|
|
169
|
+
} else {
|
|
170
|
+
groups.other[side].push(metric);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return groups;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Format metric name for display
|
|
178
|
+
function formatMetricName(name) {
|
|
179
|
+
return name
|
|
180
|
+
.replace('train_', 'Train ')
|
|
181
|
+
.replace('val_', 'Val ')
|
|
182
|
+
.replace(/_/g, ' ')
|
|
183
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Interactive Chart Component with zoom, pan, scale, and visibility controls
|
|
187
|
+
function InteractiveChart({
|
|
188
|
+
data,
|
|
189
|
+
metrics,
|
|
190
|
+
title,
|
|
191
|
+
icon,
|
|
192
|
+
iconColor,
|
|
193
|
+
compact = false,
|
|
194
|
+
defaultScale = 'linear',
|
|
195
|
+
isLossLike = true,
|
|
196
|
+
}) {
|
|
197
|
+
const [scale, setScale] = useState(defaultScale);
|
|
198
|
+
const [visibleMetrics, setVisibleMetrics] = useState(() =>
|
|
199
|
+
metrics.reduce((acc, m) => ({ ...acc, [m]: true }), {})
|
|
200
|
+
);
|
|
201
|
+
const [brushRange, setBrushRange] = useState(null);
|
|
202
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
203
|
+
|
|
204
|
+
const toggleMetric = useCallback((metric) => {
|
|
205
|
+
setVisibleMetrics(prev => ({ ...prev, [metric]: !prev[metric] }));
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
// Transform data for log scale (add small offset to avoid log(0))
|
|
209
|
+
const transformedData = useMemo(() => {
|
|
210
|
+
if (scale !== 'log') return data;
|
|
211
|
+
return data.map(point => {
|
|
212
|
+
const newPoint = { ...point };
|
|
213
|
+
metrics.forEach(metric => {
|
|
214
|
+
const displayName = formatMetricName(metric);
|
|
215
|
+
if (newPoint[displayName] !== undefined && newPoint[displayName] > 0) {
|
|
216
|
+
newPoint[displayName] = newPoint[displayName];
|
|
217
|
+
} else if (newPoint[displayName] !== undefined) {
|
|
218
|
+
newPoint[displayName] = 1e-10; // Small value for log scale
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return newPoint;
|
|
222
|
+
});
|
|
223
|
+
}, [data, metrics, scale]);
|
|
224
|
+
|
|
225
|
+
const visibleMetricsList = metrics.filter(m => visibleMetrics[m]);
|
|
226
|
+
const chartHeight = isExpanded ? 400 : (compact ? 200 : 300);
|
|
227
|
+
|
|
228
|
+
if (metrics.length === 0) return null;
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<motion.div
|
|
232
|
+
layout
|
|
233
|
+
initial={{ opacity: 0, y: 20 }}
|
|
234
|
+
animate={{ opacity: 1, y: 0 }}
|
|
235
|
+
className={`bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm overflow-hidden ${
|
|
236
|
+
isExpanded ? 'col-span-full' : ''
|
|
237
|
+
}`}
|
|
238
|
+
>
|
|
239
|
+
{/* Chart Header */}
|
|
240
|
+
<div className="p-4 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between flex-wrap gap-3">
|
|
241
|
+
<div className="flex items-center gap-2">
|
|
242
|
+
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: iconColor }} />
|
|
243
|
+
<h4 className="text-sm font-bold text-slate-700 dark:text-slate-300">{title}</h4>
|
|
244
|
+
<span className="text-xs text-slate-400">({visibleMetricsList.length}/{metrics.length} shown)</span>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
248
|
+
{/* Scale Selector */}
|
|
249
|
+
<ScaleSelector scale={scale} onChange={setScale} />
|
|
250
|
+
|
|
251
|
+
{/* Expand/Collapse Button */}
|
|
252
|
+
<button
|
|
253
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
254
|
+
className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
|
|
255
|
+
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
256
|
+
>
|
|
257
|
+
<Maximize2 size={14} className="text-slate-600 dark:text-slate-300" />
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Metric Toggles */}
|
|
263
|
+
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-700 flex flex-wrap gap-2">
|
|
264
|
+
{metrics.map((metric, idx) => (
|
|
265
|
+
<MetricToggle
|
|
266
|
+
key={metric}
|
|
267
|
+
metric={formatMetricName(metric)}
|
|
268
|
+
color={METRIC_COLORS[idx % METRIC_COLORS.length].main}
|
|
269
|
+
visible={visibleMetrics[metric]}
|
|
270
|
+
onToggle={() => toggleMetric(metric)}
|
|
271
|
+
/>
|
|
272
|
+
))}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Chart */}
|
|
276
|
+
<div className="p-4">
|
|
277
|
+
<ResponsiveContainer width="100%" height={chartHeight}>
|
|
278
|
+
<AreaChart data={transformedData}>
|
|
279
|
+
<defs>
|
|
280
|
+
{metrics.map((metric, idx) => (
|
|
281
|
+
<linearGradient key={metric} id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1">
|
|
282
|
+
<stop offset="5%" stopColor={METRIC_COLORS[idx % METRIC_COLORS.length].main} stopOpacity={0.3} />
|
|
283
|
+
<stop offset="95%" stopColor={METRIC_COLORS[idx % METRIC_COLORS.length].main} stopOpacity={0} />
|
|
284
|
+
</linearGradient>
|
|
285
|
+
))}
|
|
286
|
+
</defs>
|
|
287
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" strokeOpacity={0.5} />
|
|
288
|
+
<XAxis
|
|
289
|
+
dataKey="epoch"
|
|
290
|
+
stroke="#94a3b8"
|
|
291
|
+
tick={{ fontSize: 11 }}
|
|
292
|
+
tickLine={false}
|
|
293
|
+
label={{ value: 'Epoch', position: 'insideBottom', offset: -5, fontSize: 11, fill: '#94a3b8' }}
|
|
294
|
+
/>
|
|
295
|
+
<YAxis
|
|
296
|
+
stroke="#94a3b8"
|
|
297
|
+
tick={{ fontSize: 11 }}
|
|
298
|
+
tickLine={false}
|
|
299
|
+
scale={scale}
|
|
300
|
+
domain={scale === 'log' ? ['auto', 'auto'] : [0, 'auto']}
|
|
301
|
+
tickFormatter={(val) => {
|
|
302
|
+
if (val === 0) return '0';
|
|
303
|
+
if (Math.abs(val) < 0.001 || Math.abs(val) > 1000) {
|
|
304
|
+
return val.toExponential(1);
|
|
305
|
+
}
|
|
306
|
+
return val.toFixed(3);
|
|
307
|
+
}}
|
|
308
|
+
label={{
|
|
309
|
+
value: scale === 'log' ? 'Value (log)' : 'Value',
|
|
310
|
+
angle: -90,
|
|
311
|
+
position: 'insideLeft',
|
|
312
|
+
fontSize: 11,
|
|
313
|
+
fill: '#94a3b8'
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
316
|
+
<Tooltip content={<CustomTooltip />} />
|
|
317
|
+
<Legend
|
|
318
|
+
wrapperStyle={{ paddingTop: 10 }}
|
|
319
|
+
iconType="circle"
|
|
320
|
+
iconSize={8}
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
{/* Brush for zoom/pan */}
|
|
324
|
+
<Brush
|
|
325
|
+
dataKey="epoch"
|
|
326
|
+
height={30}
|
|
327
|
+
stroke="#94a3b8"
|
|
328
|
+
fill="#f1f5f9"
|
|
329
|
+
travellerWidth={10}
|
|
330
|
+
/>
|
|
331
|
+
|
|
332
|
+
{metrics.map((metric, idx) => (
|
|
333
|
+
visibleMetrics[metric] && (
|
|
334
|
+
<Area
|
|
335
|
+
key={metric}
|
|
336
|
+
type="monotone"
|
|
337
|
+
dataKey={formatMetricName(metric)}
|
|
338
|
+
stroke={METRIC_COLORS[idx % METRIC_COLORS.length].main}
|
|
339
|
+
strokeWidth={2}
|
|
340
|
+
fill={`url(#gradient-${metric})`}
|
|
341
|
+
dot={false}
|
|
342
|
+
activeDot={{ r: 5, strokeWidth: 2, fill: 'white' }}
|
|
343
|
+
animationDuration={500}
|
|
344
|
+
/>
|
|
345
|
+
)
|
|
346
|
+
))}
|
|
347
|
+
</AreaChart>
|
|
348
|
+
</ResponsiveContainer>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Chart Footer with instructions */}
|
|
352
|
+
<div className="px-4 py-2 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-700">
|
|
353
|
+
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
|
354
|
+
<div className="flex items-center gap-4">
|
|
355
|
+
<span className="flex items-center gap-1">
|
|
356
|
+
<ZoomIn size={12} /> Drag brush to zoom
|
|
357
|
+
</span>
|
|
358
|
+
<span className="flex items-center gap-1">
|
|
359
|
+
<Eye size={12} /> Click legend to toggle
|
|
360
|
+
</span>
|
|
361
|
+
</div>
|
|
362
|
+
<span>{data.length} data points</span>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</motion.div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Main Training History Chart Component
|
|
370
|
+
export function TrainingHistoryChart({ trainingHistory, title = "Training History", compact = false }) {
|
|
371
|
+
const { chartData, metricGroups, allMetrics, summary } = useMemo(() => {
|
|
372
|
+
if (!trainingHistory || !trainingHistory.epochs || trainingHistory.epochs.length === 0) {
|
|
373
|
+
return { chartData: [], metricGroups: {}, allMetrics: [], summary: null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const epochs = trainingHistory.epochs;
|
|
377
|
+
const data = epochs.map((epoch, idx) => {
|
|
378
|
+
const point = { epoch };
|
|
379
|
+
Object.keys(trainingHistory).forEach(key => {
|
|
380
|
+
if (key !== 'epochs' && trainingHistory[key]?.[idx] !== undefined) {
|
|
381
|
+
point[formatMetricName(key)] = trainingHistory[key][idx];
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
return point;
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const metrics = Object.keys(trainingHistory).filter(k => k !== 'epochs' && trainingHistory[k]?.length > 0);
|
|
388
|
+
const groups = groupMetrics(metrics);
|
|
389
|
+
|
|
390
|
+
const summaryData = {
|
|
391
|
+
totalEpochs: epochs.length,
|
|
392
|
+
metrics: {},
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
metrics.forEach(metric => {
|
|
396
|
+
const values = trainingHistory[metric];
|
|
397
|
+
if (!values || values.length === 0) return;
|
|
398
|
+
|
|
399
|
+
const lastValue = values[values.length - 1];
|
|
400
|
+
const isLossLike = metric.includes('loss') || metric.includes('mae') || metric.includes('mse') || metric.includes('error');
|
|
401
|
+
const bestValue = isLossLike ? Math.min(...values) : Math.max(...values);
|
|
402
|
+
|
|
403
|
+
summaryData.metrics[metric] = {
|
|
404
|
+
final: lastValue,
|
|
405
|
+
best: bestValue,
|
|
406
|
+
isLossLike,
|
|
407
|
+
trend: values.length > 1 ? (values[values.length - 1] < values[values.length - 2] ? 'down' : 'up') : null,
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return { chartData: data, metricGroups: groups, allMetrics: metrics, summary: summaryData };
|
|
412
|
+
}, [trainingHistory]);
|
|
413
|
+
|
|
414
|
+
// Don't render anything if no data
|
|
415
|
+
if (chartData.length === 0) {
|
|
416
|
+
return null; // Return null instead of placeholder - component won't show if no data
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const lossMetrics = [...metricGroups.loss.train, ...metricGroups.loss.val];
|
|
420
|
+
const accuracyMetrics = [...metricGroups.accuracy.train, ...metricGroups.accuracy.val];
|
|
421
|
+
const otherMetrics = [...metricGroups.other.train, ...metricGroups.other.val];
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<div className="space-y-6">
|
|
425
|
+
{/* Header with Summary Cards */}
|
|
426
|
+
{!compact && summary && (
|
|
427
|
+
<motion.div
|
|
428
|
+
initial="hidden"
|
|
429
|
+
animate="visible"
|
|
430
|
+
variants={{
|
|
431
|
+
hidden: { opacity: 0 },
|
|
432
|
+
visible: { opacity: 1, transition: { staggerChildren: 0.1 } }
|
|
433
|
+
}}
|
|
434
|
+
className="grid grid-cols-2 md:grid-cols-4 gap-4"
|
|
435
|
+
>
|
|
436
|
+
<MetricCard
|
|
437
|
+
icon={Zap}
|
|
438
|
+
label="Epochs Trained"
|
|
439
|
+
value={summary.totalEpochs}
|
|
440
|
+
color={METRIC_COLORS[0].main}
|
|
441
|
+
/>
|
|
442
|
+
{summary.metrics.val_loss && (
|
|
443
|
+
<MetricCard
|
|
444
|
+
icon={TrendingDown}
|
|
445
|
+
label="Best Val Loss"
|
|
446
|
+
value={summary.metrics.val_loss.best}
|
|
447
|
+
trend={summary.metrics.val_loss.trend}
|
|
448
|
+
color={METRIC_COLORS[1].main}
|
|
449
|
+
/>
|
|
450
|
+
)}
|
|
451
|
+
{summary.metrics.val_mae && (
|
|
452
|
+
<MetricCard
|
|
453
|
+
icon={Target}
|
|
454
|
+
label="Best Val MAE"
|
|
455
|
+
value={summary.metrics.val_mae.best}
|
|
456
|
+
trend={summary.metrics.val_mae.trend}
|
|
457
|
+
color={METRIC_COLORS[4].main}
|
|
458
|
+
/>
|
|
459
|
+
)}
|
|
460
|
+
{summary.metrics.val_accuracy && (
|
|
461
|
+
<MetricCard
|
|
462
|
+
icon={TrendingUp}
|
|
463
|
+
label="Best Val Accuracy"
|
|
464
|
+
value={summary.metrics.val_accuracy.best}
|
|
465
|
+
trend={summary.metrics.val_accuracy.trend}
|
|
466
|
+
color={METRIC_COLORS[2].main}
|
|
467
|
+
/>
|
|
468
|
+
)}
|
|
469
|
+
</motion.div>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{/* Interactive Charts */}
|
|
473
|
+
<div className={`grid gap-6 ${!compact && (lossMetrics.length > 0 && accuracyMetrics.length > 0) ? 'lg:grid-cols-2' : ''}`}>
|
|
474
|
+
{lossMetrics.length > 0 && (
|
|
475
|
+
<InteractiveChart
|
|
476
|
+
data={chartData}
|
|
477
|
+
metrics={lossMetrics}
|
|
478
|
+
title="Loss & Error Metrics"
|
|
479
|
+
iconColor="#3b82f6"
|
|
480
|
+
compact={compact}
|
|
481
|
+
defaultScale="linear"
|
|
482
|
+
isLossLike={true}
|
|
483
|
+
/>
|
|
484
|
+
)}
|
|
485
|
+
|
|
486
|
+
{accuracyMetrics.length > 0 && (
|
|
487
|
+
<InteractiveChart
|
|
488
|
+
data={chartData}
|
|
489
|
+
metrics={accuracyMetrics}
|
|
490
|
+
title="Accuracy & Score Metrics"
|
|
491
|
+
iconColor="#10b981"
|
|
492
|
+
compact={compact}
|
|
493
|
+
defaultScale="linear"
|
|
494
|
+
isLossLike={false}
|
|
495
|
+
/>
|
|
496
|
+
)}
|
|
497
|
+
|
|
498
|
+
{otherMetrics.length > 0 && (
|
|
499
|
+
<InteractiveChart
|
|
500
|
+
data={chartData}
|
|
501
|
+
metrics={otherMetrics}
|
|
502
|
+
title="Additional Metrics"
|
|
503
|
+
iconColor="#06b6d4"
|
|
504
|
+
compact={compact}
|
|
505
|
+
defaultScale="linear"
|
|
506
|
+
isLossLike={true}
|
|
507
|
+
/>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export default TrainingHistoryChart;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { fetchApi } from '../utils/api';
|
|
3
|
+
import { TrainingHistoryChart } from './TrainingHistoryChart';
|
|
4
|
+
import { Activity, TrendingUp, RefreshCcw, Download, ExternalLink } from 'lucide-react';
|
|
5
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
6
|
+
import { Button } from './ui/Button';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TrainingMetricsPanel - A comprehensive panel for displaying training metrics
|
|
10
|
+
* in the run details page. Fetches training history from the API and displays
|
|
11
|
+
* beautiful interactive charts.
|
|
12
|
+
*
|
|
13
|
+
* IMPORTANT: This component returns null if no training data is available,
|
|
14
|
+
* so it won't render anything for non-training pipelines.
|
|
15
|
+
*/
|
|
16
|
+
export function TrainingMetricsPanel({ runId, isRunning = false, autoRefresh = true }) {
|
|
17
|
+
const [trainingHistory, setTrainingHistory] = useState(null);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
const [lastUpdated, setLastUpdated] = useState(null);
|
|
21
|
+
const [hasChecked, setHasChecked] = useState(false);
|
|
22
|
+
|
|
23
|
+
const fetchTrainingHistory = async () => {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetchApi(`/api/runs/${runId}/training-history`);
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
throw new Error('Failed to fetch training history');
|
|
28
|
+
}
|
|
29
|
+
const data = await res.json();
|
|
30
|
+
|
|
31
|
+
if (data.has_history && data.training_history?.epochs?.length > 0) {
|
|
32
|
+
setTrainingHistory(data.training_history);
|
|
33
|
+
setLastUpdated(new Date());
|
|
34
|
+
setError(null);
|
|
35
|
+
} else {
|
|
36
|
+
setTrainingHistory(null);
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('Error fetching training history:', err);
|
|
40
|
+
setError(err.message);
|
|
41
|
+
setTrainingHistory(null);
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
setHasChecked(true);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Initial fetch
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (runId) {
|
|
51
|
+
fetchTrainingHistory();
|
|
52
|
+
}
|
|
53
|
+
}, [runId]);
|
|
54
|
+
|
|
55
|
+
// Auto-refresh while running
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!isRunning || !autoRefresh) return;
|
|
58
|
+
|
|
59
|
+
const interval = setInterval(fetchTrainingHistory, 5000);
|
|
60
|
+
return () => clearInterval(interval);
|
|
61
|
+
}, [runId, isRunning, autoRefresh]);
|
|
62
|
+
|
|
63
|
+
// Export training history as JSON
|
|
64
|
+
const handleExport = () => {
|
|
65
|
+
if (!trainingHistory) return;
|
|
66
|
+
|
|
67
|
+
const blob = new Blob([JSON.stringify(trainingHistory, null, 2)], {
|
|
68
|
+
type: 'application/json'
|
|
69
|
+
});
|
|
70
|
+
const url = URL.createObjectURL(blob);
|
|
71
|
+
const a = document.createElement('a');
|
|
72
|
+
a.href = url;
|
|
73
|
+
a.download = `training-history-${runId}.json`;
|
|
74
|
+
a.click();
|
|
75
|
+
URL.revokeObjectURL(url);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Show loading only briefly on first load
|
|
79
|
+
if (loading && !hasChecked) {
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex items-center justify-center p-6">
|
|
82
|
+
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-200 border-t-primary-600" />
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Don't render anything if there's no training history
|
|
88
|
+
// This makes the section completely disappear for non-training pipelines
|
|
89
|
+
if (!trainingHistory || !trainingHistory.epochs?.length) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const totalEpochs = trainingHistory.epochs?.length || 0;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<motion.div
|
|
97
|
+
initial={{ opacity: 0, y: 20 }}
|
|
98
|
+
animate={{ opacity: 1, y: 0 }}
|
|
99
|
+
className="space-y-6"
|
|
100
|
+
>
|
|
101
|
+
{/* Header */}
|
|
102
|
+
<div className="flex items-center justify-between">
|
|
103
|
+
<div className="flex items-center gap-3">
|
|
104
|
+
<div className="p-2.5 bg-gradient-to-br from-primary-500 to-purple-500 rounded-xl shadow-lg">
|
|
105
|
+
<TrendingUp size={20} className="text-white" />
|
|
106
|
+
</div>
|
|
107
|
+
<div>
|
|
108
|
+
<h3 className="text-lg font-bold text-slate-900 dark:text-white">
|
|
109
|
+
Training Metrics
|
|
110
|
+
</h3>
|
|
111
|
+
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
112
|
+
{totalEpochs} epochs recorded
|
|
113
|
+
{lastUpdated && (
|
|
114
|
+
<span className="text-xs text-slate-400 ml-2">
|
|
115
|
+
• Updated {lastUpdated.toLocaleTimeString()}
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className="flex items-center gap-2">
|
|
123
|
+
{isRunning && (
|
|
124
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 dark:bg-amber-900/30 rounded-full border border-amber-200 dark:border-amber-800">
|
|
125
|
+
<Activity size={14} className="text-amber-600 dark:text-amber-400 animate-pulse" />
|
|
126
|
+
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
|
127
|
+
Live
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
<Button
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="sm"
|
|
134
|
+
onClick={fetchTrainingHistory}
|
|
135
|
+
className="text-slate-500 hover:text-slate-700"
|
|
136
|
+
>
|
|
137
|
+
<RefreshCcw size={14} />
|
|
138
|
+
</Button>
|
|
139
|
+
<Button
|
|
140
|
+
variant="outline"
|
|
141
|
+
size="sm"
|
|
142
|
+
onClick={handleExport}
|
|
143
|
+
className="text-slate-600"
|
|
144
|
+
>
|
|
145
|
+
<Download size={14} className="mr-1.5" />
|
|
146
|
+
Export
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Training History Charts */}
|
|
152
|
+
<TrainingHistoryChart trainingHistory={trainingHistory} compact={false} />
|
|
153
|
+
|
|
154
|
+
{/* Quick Stats Footer */}
|
|
155
|
+
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
|
156
|
+
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
|
157
|
+
<span>
|
|
158
|
+
💡 Tip: Hover over the charts for detailed epoch-by-epoch values
|
|
159
|
+
</span>
|
|
160
|
+
<a
|
|
161
|
+
href="https://flowyml.readthedocs.io/integrations/keras/"
|
|
162
|
+
target="_blank"
|
|
163
|
+
rel="noopener noreferrer"
|
|
164
|
+
className="flex items-center gap-1 hover:text-primary-600 transition-colors"
|
|
165
|
+
>
|
|
166
|
+
Learn more about Keras integration
|
|
167
|
+
<ExternalLink size={12} />
|
|
168
|
+
</a>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</motion.div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default TrainingMetricsPanel;
|