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.
Files changed (35) hide show
  1. flowyml/assets/dataset.py +570 -17
  2. flowyml/assets/model.py +1052 -15
  3. flowyml/core/executor.py +70 -11
  4. flowyml/core/orchestrator.py +37 -2
  5. flowyml/core/pipeline.py +32 -4
  6. flowyml/core/scheduler.py +88 -5
  7. flowyml/integrations/keras.py +247 -82
  8. flowyml/storage/sql.py +24 -6
  9. flowyml/ui/backend/routers/runs.py +112 -0
  10. flowyml/ui/backend/routers/schedules.py +35 -15
  11. flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +1 -0
  12. flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +685 -0
  13. flowyml/ui/frontend/dist/index.html +2 -2
  14. flowyml/ui/frontend/package-lock.json +11 -0
  15. flowyml/ui/frontend/package.json +1 -0
  16. flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
  17. flowyml/ui/frontend/src/app/dashboard/page.jsx +1 -1
  18. flowyml/ui/frontend/src/app/experiments/[experimentId]/page.jsx +1 -1
  19. flowyml/ui/frontend/src/app/leaderboard/page.jsx +1 -1
  20. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
  21. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +3 -3
  22. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +590 -102
  23. flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
  24. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +401 -28
  25. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
  26. flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
  27. flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
  28. flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
  29. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/METADATA +1 -1
  30. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/RECORD +33 -30
  31. flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
  32. flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +0 -630
  33. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/WHEEL +0 -0
  34. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/entry_points.txt +0 -0
  35. {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;