flowyml 1.7.1__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/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/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +589 -101
- 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.1.dist-info → flowyml-1.7.2.dist-info}/METADATA +1 -1
- {flowyml-1.7.1.dist-info → flowyml-1.7.2.dist-info}/RECORD +28 -25
- flowyml/ui/frontend/dist/assets/index-BqDQvp63.js +0 -630
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
- {flowyml-1.7.1.dist-info → flowyml-1.7.2.dist-info}/WHEEL +0 -0
- {flowyml-1.7.1.dist-info → flowyml-1.7.2.dist-info}/entry_points.txt +0 -0
- {flowyml-1.7.1.dist-info → flowyml-1.7.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useMemo } from 'react';
|
|
|
2
2
|
import { fetchApi } from '../../../utils/api';
|
|
3
3
|
import { downloadArtifactById } from '../../../utils/downloads';
|
|
4
4
|
import { useParams, Link } from 'react-router-dom';
|
|
5
|
-
import { CheckCircle, XCircle, Clock, Calendar, Package, ArrowRight, BarChart2, FileText, Database, Box, ChevronRight, Activity, Layers, Code2, Terminal, Info, X, Maximize2, TrendingUp, Download, ArrowDownCircle, ArrowUpCircle, Tag, Zap, AlertCircle, FolderPlus, Cloud, Server } from 'lucide-react';
|
|
5
|
+
import { CheckCircle, XCircle, Clock, Calendar, Package, ArrowRight, BarChart2, FileText, Database, Box, ChevronRight, Activity, Layers, Code2, Terminal, Info, X, Maximize2, TrendingUp, TrendingDown, Download, ArrowDownCircle, ArrowUpCircle, Tag, Zap, AlertCircle, FolderPlus, Cloud, Server, LineChart, Minimize2 } from 'lucide-react';
|
|
6
6
|
import { Card } from '../../../components/ui/Card';
|
|
7
7
|
import { Badge } from '../../../components/ui/Badge';
|
|
8
8
|
import { Button } from '../../../components/ui/Button';
|
|
@@ -12,6 +12,8 @@ import { ArtifactViewer } from '../../../components/ArtifactViewer';
|
|
|
12
12
|
import { PipelineGraph } from '../../../components/PipelineGraph';
|
|
13
13
|
import { ProjectSelector } from '../../../components/ProjectSelector';
|
|
14
14
|
import { CodeSnippet } from '../../../components/ui/CodeSnippet';
|
|
15
|
+
import { TrainingMetricsPanel } from '../../../components/TrainingMetricsPanel';
|
|
16
|
+
import { TrainingHistoryChart } from '../../../components/TrainingHistoryChart';
|
|
15
17
|
|
|
16
18
|
export function RunDetails() {
|
|
17
19
|
const { runId } = useParams();
|
|
@@ -26,6 +28,8 @@ export function RunDetails() {
|
|
|
26
28
|
const [isPolling, setIsPolling] = useState(false);
|
|
27
29
|
const [stopping, setStopping] = useState(false);
|
|
28
30
|
const [stepLogs, setStepLogs] = useState({});
|
|
31
|
+
const [stepPanelExpanded, setStepPanelExpanded] = useState(false);
|
|
32
|
+
const [hasTrainingHistory, setHasTrainingHistory] = useState(false);
|
|
29
33
|
|
|
30
34
|
const handleStopRun = async () => {
|
|
31
35
|
if (!confirm('Are you sure you want to stop this run?')) return;
|
|
@@ -227,30 +231,70 @@ export function RunDetails() {
|
|
|
227
231
|
/>
|
|
228
232
|
</div>
|
|
229
233
|
|
|
230
|
-
{/* Main Content - Split View */}
|
|
231
|
-
<div className=
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
{/* Main Content - Dynamic Split View */}
|
|
235
|
+
<div className={`grid gap-6 transition-all duration-300 ${
|
|
236
|
+
stepPanelExpanded
|
|
237
|
+
? 'grid-cols-1 lg:grid-cols-2'
|
|
238
|
+
: 'grid-cols-1 lg:grid-cols-3'
|
|
239
|
+
}`}>
|
|
240
|
+
{/* DAG Visualization */}
|
|
241
|
+
<div className={stepPanelExpanded ? 'lg:col-span-1' : 'lg:col-span-2'}>
|
|
234
242
|
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
|
|
235
243
|
<Activity className="text-primary-500" /> Pipeline Execution Graph
|
|
236
244
|
</h3>
|
|
237
|
-
<div className=
|
|
245
|
+
<div className={`min-h-[500px] ${stepPanelExpanded ? 'h-[500px]' : 'h-[calc(100vh-240px)]'}`}>
|
|
238
246
|
{run.dag ? (
|
|
239
247
|
<PipelineGraph
|
|
240
248
|
dag={run.dag}
|
|
241
249
|
steps={run.steps}
|
|
242
250
|
selectedStep={selectedStep}
|
|
243
251
|
onStepSelect={setSelectedStep}
|
|
244
|
-
onArtifactSelect={(
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
252
|
+
onArtifactSelect={(artifactLabel) => {
|
|
253
|
+
// Smart artifact matching:
|
|
254
|
+
// DAG labels are like "data/train", "model/trained"
|
|
255
|
+
// API names are like "train_dataset", "trained_model"
|
|
256
|
+
|
|
257
|
+
// 1. Exact name match
|
|
258
|
+
let found = artifacts.find(a => a.name === artifactLabel);
|
|
259
|
+
|
|
260
|
+
if (!found) {
|
|
261
|
+
// 2. Match by output pattern
|
|
262
|
+
// "data/train" -> look for artifacts with "train" and type Dataset
|
|
263
|
+
// "model/trained" -> look for artifacts with "trained" and type Model
|
|
264
|
+
const parts = artifactLabel.split('/');
|
|
265
|
+
const prefix = parts[0]; // "data", "model", "metrics"
|
|
266
|
+
const suffix = parts[1]; // "train", "trained", "evaluation"
|
|
267
|
+
|
|
268
|
+
const typeMap = {
|
|
269
|
+
'data': 'Dataset',
|
|
270
|
+
'model': 'Model',
|
|
271
|
+
'metrics': 'Metrics',
|
|
272
|
+
'features': 'FeatureSet',
|
|
273
|
+
};
|
|
274
|
+
const expectedType = typeMap[prefix];
|
|
275
|
+
|
|
276
|
+
// Look for artifacts with matching type and suffix in name
|
|
277
|
+
if (suffix && expectedType) {
|
|
278
|
+
found = artifacts.find(a =>
|
|
279
|
+
a.type === expectedType &&
|
|
280
|
+
(a.name.toLowerCase().includes(suffix.toLowerCase()) ||
|
|
281
|
+
suffix.toLowerCase().includes(a.name.toLowerCase().replace(/_/g, '')))
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 3. Fallback: match by type alone if only one of that type
|
|
286
|
+
if (!found && expectedType) {
|
|
287
|
+
const typeMatches = artifacts.filter(a => a.type === expectedType);
|
|
288
|
+
if (typeMatches.length === 1) {
|
|
289
|
+
found = typeMatches[0];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
248
294
|
if (found) {
|
|
249
295
|
setSelectedArtifact(found);
|
|
250
296
|
} else {
|
|
251
|
-
|
|
252
|
-
console.warn(`Artifact ${name} not found in assets list`);
|
|
253
|
-
// Optionally show a toast or alert
|
|
297
|
+
console.warn(`Artifact "${artifactLabel}" not found in assets list. Available:`, artifacts.map(a => a.name));
|
|
254
298
|
}
|
|
255
299
|
}}
|
|
256
300
|
/>
|
|
@@ -262,18 +306,31 @@ export function RunDetails() {
|
|
|
262
306
|
</div>
|
|
263
307
|
</div>
|
|
264
308
|
|
|
265
|
-
{/* Step Details Panel -
|
|
266
|
-
<div>
|
|
267
|
-
<
|
|
268
|
-
<
|
|
269
|
-
|
|
309
|
+
{/* Step Details Panel - Expandable */}
|
|
310
|
+
<div className={stepPanelExpanded ? 'lg:col-span-1' : ''}>
|
|
311
|
+
<div className="flex items-center justify-between mb-4">
|
|
312
|
+
<h3 className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
|
313
|
+
<Info className="text-primary-500" /> Step Details
|
|
314
|
+
</h3>
|
|
315
|
+
<button
|
|
316
|
+
onClick={() => setStepPanelExpanded(!stepPanelExpanded)}
|
|
317
|
+
className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
|
318
|
+
title={stepPanelExpanded ? 'Collapse panel' : 'Expand panel'}
|
|
319
|
+
>
|
|
320
|
+
<Maximize2 size={16} className="text-slate-600 dark:text-slate-300" />
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
270
323
|
|
|
271
324
|
{selectedStepData ? (
|
|
272
|
-
<Card className=
|
|
325
|
+
<Card className={`overflow-hidden transition-all duration-300 ${
|
|
326
|
+
stepPanelExpanded ? 'h-auto' : ''
|
|
327
|
+
}`}>
|
|
273
328
|
{/* Step Header */}
|
|
274
329
|
<div className="pb-4 border-b border-slate-100 dark:border-slate-700">
|
|
275
|
-
<
|
|
276
|
-
|
|
330
|
+
<div className="flex items-center justify-between">
|
|
331
|
+
<h4 className="text-lg font-bold text-slate-900 dark:text-white">{selectedStep}</h4>
|
|
332
|
+
</div>
|
|
333
|
+
<div className="flex items-center gap-2 mt-2">
|
|
277
334
|
<Badge variant={selectedStepData.success ? 'success' : 'danger'} className="text-xs">
|
|
278
335
|
{selectedStepData.success ? 'Success' : 'Failed'}
|
|
279
336
|
</Badge>
|
|
@@ -288,7 +345,7 @@ export function RunDetails() {
|
|
|
288
345
|
</div>
|
|
289
346
|
</div>
|
|
290
347
|
|
|
291
|
-
{/* Live Heartbeat Indicator
|
|
348
|
+
{/* Live Heartbeat Indicator */}
|
|
292
349
|
{selectedStepData.status === 'running' && (
|
|
293
350
|
<div className="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800 flex items-center justify-between">
|
|
294
351
|
<div className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-300">
|
|
@@ -312,23 +369,25 @@ export function RunDetails() {
|
|
|
312
369
|
)}
|
|
313
370
|
|
|
314
371
|
{/* Tabs */}
|
|
315
|
-
<div className="flex gap-
|
|
372
|
+
<div className="flex gap-1 border-b border-slate-100 dark:border-slate-700 mt-4 overflow-x-auto">
|
|
316
373
|
<TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')}>
|
|
317
|
-
<Info size={
|
|
374
|
+
<Info size={14} /> Overview
|
|
318
375
|
</TabButton>
|
|
319
376
|
<TabButton active={activeTab === 'code'} onClick={() => setActiveTab('code')}>
|
|
320
|
-
<Code2 size={
|
|
377
|
+
<Code2 size={14} /> Code
|
|
321
378
|
</TabButton>
|
|
322
379
|
<TabButton active={activeTab === 'artifacts'} onClick={() => setActiveTab('artifacts')}>
|
|
323
|
-
<Package size={
|
|
380
|
+
<Package size={14} /> Artifacts
|
|
324
381
|
</TabButton>
|
|
325
382
|
<TabButton active={activeTab === 'logs'} onClick={() => setActiveTab('logs')} data-tab="logs">
|
|
326
|
-
<Terminal size={
|
|
383
|
+
<Terminal size={14} /> Logs
|
|
327
384
|
</TabButton>
|
|
328
385
|
</div>
|
|
329
386
|
|
|
330
|
-
{/* Tab Content */}
|
|
331
|
-
<div className=
|
|
387
|
+
{/* Tab Content - Larger when expanded */}
|
|
388
|
+
<div className={`mt-4 overflow-y-auto transition-all duration-300 ${
|
|
389
|
+
stepPanelExpanded ? 'max-h-[600px]' : 'max-h-[450px]'
|
|
390
|
+
}`}>
|
|
332
391
|
{activeTab === 'overview' && (
|
|
333
392
|
<OverviewTab
|
|
334
393
|
stepData={selectedStepData}
|
|
@@ -351,18 +410,27 @@ export function RunDetails() {
|
|
|
351
410
|
runId={runId}
|
|
352
411
|
stepName={selectedStep}
|
|
353
412
|
isRunning={run.status === 'running'}
|
|
413
|
+
maxHeight={stepPanelExpanded ? 'max-h-[500px]' : 'max-h-96'}
|
|
354
414
|
/>
|
|
355
415
|
)}
|
|
356
416
|
</div>
|
|
357
417
|
</Card>
|
|
358
418
|
) : (
|
|
359
|
-
<Card className="p-12 text-center">
|
|
360
|
-
<
|
|
419
|
+
<Card className="p-12 text-center border-2 border-dashed border-slate-200 dark:border-slate-700">
|
|
420
|
+
<Info size={32} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
|
|
421
|
+
<p className="text-slate-500 dark:text-slate-400 font-medium">Select a step to view details</p>
|
|
422
|
+
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">Click on any step in the graph</p>
|
|
361
423
|
</Card>
|
|
362
424
|
)}
|
|
363
425
|
</div>
|
|
364
426
|
</div>
|
|
365
427
|
|
|
428
|
+
{/* Training Metrics Section - Only shown if there's training data */}
|
|
429
|
+
<TrainingMetricsSectionWrapper
|
|
430
|
+
runId={runId}
|
|
431
|
+
isRunning={run.status === 'running'}
|
|
432
|
+
/>
|
|
433
|
+
|
|
366
434
|
{/* Artifact Detail Modal */}
|
|
367
435
|
<ArtifactModal
|
|
368
436
|
artifact={selectedArtifact}
|
|
@@ -712,105 +780,342 @@ function ArtifactsTab({ artifacts, onArtifactClick }) {
|
|
|
712
780
|
}
|
|
713
781
|
|
|
714
782
|
function ArtifactModal({ artifact, onClose }) {
|
|
783
|
+
const [activeTab, setActiveTab] = useState('visualization');
|
|
784
|
+
|
|
715
785
|
if (!artifact) return null;
|
|
716
786
|
|
|
787
|
+
const isDataset = artifact.type === 'Dataset' || artifact.asset_type === 'Dataset';
|
|
788
|
+
const isModel = artifact.type === 'Model' || artifact.asset_type === 'Model';
|
|
789
|
+
const isMetrics = artifact.type === 'Metrics' || artifact.asset_type === 'Metrics';
|
|
790
|
+
|
|
791
|
+
// Get properties for display (filter out internal/large ones)
|
|
792
|
+
const displayableProps = artifact.properties
|
|
793
|
+
? Object.entries(artifact.properties).filter(([key, value]) => {
|
|
794
|
+
// Skip internal properties and very large values
|
|
795
|
+
if (key.startsWith('_')) return false;
|
|
796
|
+
if (typeof value === 'string' && value.length > 500) return false;
|
|
797
|
+
return true;
|
|
798
|
+
})
|
|
799
|
+
: [];
|
|
800
|
+
|
|
801
|
+
// Check for training history
|
|
802
|
+
const hasTrainingHistory = artifact.training_history &&
|
|
803
|
+
artifact.training_history.epochs &&
|
|
804
|
+
artifact.training_history.epochs.length > 0;
|
|
805
|
+
|
|
806
|
+
// Also check in properties for training history
|
|
807
|
+
const trainingHistoryFromProps = artifact.properties?.training_history;
|
|
808
|
+
const effectiveTrainingHistory = artifact.training_history || (
|
|
809
|
+
trainingHistoryFromProps && trainingHistoryFromProps.epochs
|
|
810
|
+
? trainingHistoryFromProps
|
|
811
|
+
: null
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
// Get icon and gradient based on type
|
|
815
|
+
const getTypeStyle = () => {
|
|
816
|
+
if (isDataset) return {
|
|
817
|
+
icon: <Database size={28} />,
|
|
818
|
+
gradient: 'from-blue-500 to-indigo-600',
|
|
819
|
+
bgGradient: 'from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20',
|
|
820
|
+
iconBg: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
|
821
|
+
};
|
|
822
|
+
if (isModel) return {
|
|
823
|
+
icon: <Box size={28} />,
|
|
824
|
+
gradient: 'from-purple-500 to-violet-600',
|
|
825
|
+
bgGradient: 'from-purple-50 to-violet-50 dark:from-purple-900/20 dark:to-violet-900/20',
|
|
826
|
+
iconBg: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
|
|
827
|
+
};
|
|
828
|
+
if (isMetrics) return {
|
|
829
|
+
icon: <BarChart2 size={28} />,
|
|
830
|
+
gradient: 'from-emerald-500 to-teal-600',
|
|
831
|
+
bgGradient: 'from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20',
|
|
832
|
+
iconBg: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400'
|
|
833
|
+
};
|
|
834
|
+
return {
|
|
835
|
+
icon: <FileText size={28} />,
|
|
836
|
+
gradient: 'from-slate-500 to-slate-600',
|
|
837
|
+
bgGradient: 'from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-900',
|
|
838
|
+
iconBg: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
|
|
839
|
+
};
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const typeStyle = getTypeStyle();
|
|
843
|
+
|
|
844
|
+
// Get model-specific properties
|
|
845
|
+
const modelInfo = isModel ? {
|
|
846
|
+
framework: artifact.properties?.framework || 'Unknown',
|
|
847
|
+
parameters: artifact.properties?.parameters,
|
|
848
|
+
layers: artifact.properties?.num_layers,
|
|
849
|
+
optimizer: artifact.properties?.optimizer,
|
|
850
|
+
learningRate: artifact.properties?.learning_rate,
|
|
851
|
+
epochsTrained: artifact.properties?.epochs_trained || effectiveTrainingHistory?.epochs?.length,
|
|
852
|
+
} : null;
|
|
853
|
+
|
|
854
|
+
// Get dataset-specific properties
|
|
855
|
+
const datasetInfo = isDataset ? {
|
|
856
|
+
samples: artifact.properties?.num_samples || artifact.properties?.samples,
|
|
857
|
+
features: artifact.properties?.num_features,
|
|
858
|
+
source: artifact.properties?.source,
|
|
859
|
+
split: artifact.properties?.split,
|
|
860
|
+
} : null;
|
|
861
|
+
|
|
862
|
+
// Get metrics-specific data
|
|
863
|
+
const metricsData = isMetrics && artifact.properties ? Object.entries(artifact.properties)
|
|
864
|
+
.filter(([key, value]) => typeof value === 'number' && !key.startsWith('_'))
|
|
865
|
+
.sort((a, b) => a[0].localeCompare(b[0])) : [];
|
|
866
|
+
|
|
717
867
|
return (
|
|
718
868
|
<AnimatePresence>
|
|
719
869
|
<motion.div
|
|
720
870
|
initial={{ opacity: 0 }}
|
|
721
871
|
animate={{ opacity: 1 }}
|
|
722
872
|
exit={{ opacity: 0 }}
|
|
723
|
-
className="fixed inset-0 bg-black/
|
|
873
|
+
className="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4"
|
|
724
874
|
onClick={onClose}
|
|
725
875
|
>
|
|
726
876
|
<motion.div
|
|
727
|
-
initial={{ scale: 0.9, opacity: 0 }}
|
|
728
|
-
animate={{ scale: 1, opacity: 1 }}
|
|
729
|
-
exit={{ scale: 0.9, opacity: 0 }}
|
|
877
|
+
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
|
878
|
+
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
879
|
+
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
|
880
|
+
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
|
730
881
|
onClick={(e) => e.stopPropagation()}
|
|
731
|
-
className="bg-white rounded-2xl shadow-2xl
|
|
882
|
+
className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col"
|
|
732
883
|
>
|
|
733
|
-
{/* Header */}
|
|
734
|
-
<div className=
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
884
|
+
{/* Header with gradient */}
|
|
885
|
+
<div className={`relative p-6 bg-gradient-to-r ${typeStyle.bgGradient} border-b border-slate-200 dark:border-slate-700`}>
|
|
886
|
+
{/* Background decoration */}
|
|
887
|
+
<div className="absolute top-0 right-0 w-64 h-64 opacity-10 pointer-events-none">
|
|
888
|
+
<div className={`w-full h-full bg-gradient-to-br ${typeStyle.gradient} rounded-full blur-3xl`} />
|
|
889
|
+
</div>
|
|
890
|
+
|
|
891
|
+
<div className="relative flex items-start justify-between">
|
|
892
|
+
<div className="flex items-center gap-4">
|
|
893
|
+
<div className={`p-4 rounded-2xl shadow-lg bg-gradient-to-br ${typeStyle.gradient}`}>
|
|
894
|
+
<div className="text-white">
|
|
895
|
+
{typeStyle.icon}
|
|
896
|
+
</div>
|
|
897
|
+
</div>
|
|
898
|
+
<div>
|
|
899
|
+
<h3 className="text-2xl font-bold text-slate-900 dark:text-white">{artifact.name}</h3>
|
|
900
|
+
<div className="flex items-center gap-3 mt-1">
|
|
901
|
+
<span className={`text-sm font-medium px-3 py-0.5 rounded-full ${typeStyle.iconBg}`}>
|
|
902
|
+
{artifact.type}
|
|
903
|
+
</span>
|
|
904
|
+
{artifact.step && (
|
|
905
|
+
<span className="text-sm text-slate-500 dark:text-slate-400">
|
|
906
|
+
from <span className="font-mono font-medium">{artifact.step}</span>
|
|
907
|
+
</span>
|
|
908
|
+
)}
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
741
911
|
</div>
|
|
742
|
-
<div>
|
|
743
|
-
<
|
|
744
|
-
|
|
912
|
+
<div className="flex items-center gap-2">
|
|
913
|
+
<button
|
|
914
|
+
onClick={() => downloadArtifactById(artifact.artifact_id)}
|
|
915
|
+
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r ${typeStyle.gradient} text-white text-sm font-semibold hover:shadow-lg transition-all disabled:opacity-50`}
|
|
916
|
+
disabled={!artifact.artifact_id}
|
|
917
|
+
>
|
|
918
|
+
<Download size={16} /> Download
|
|
919
|
+
</button>
|
|
920
|
+
<button
|
|
921
|
+
onClick={onClose}
|
|
922
|
+
className="p-2.5 hover:bg-white/50 dark:hover:bg-slate-800 rounded-xl transition-colors"
|
|
923
|
+
>
|
|
924
|
+
<X size={22} className="text-slate-500 dark:text-slate-400" />
|
|
925
|
+
</button>
|
|
745
926
|
</div>
|
|
746
927
|
</div>
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
<
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
928
|
+
|
|
929
|
+
{/* Quick stats bar for Models */}
|
|
930
|
+
{isModel && modelInfo && (
|
|
931
|
+
<div className="mt-4 flex flex-wrap gap-4">
|
|
932
|
+
<QuickStat label="Framework" value={modelInfo.framework} icon={<Zap size={12} />} />
|
|
933
|
+
{modelInfo.parameters && <QuickStat label="Parameters" value={modelInfo.parameters.toLocaleString()} icon={<Activity size={12} />} />}
|
|
934
|
+
{modelInfo.layers && <QuickStat label="Layers" value={modelInfo.layers} icon={<Layers size={12} />} />}
|
|
935
|
+
{modelInfo.epochsTrained && <QuickStat label="Epochs" value={modelInfo.epochsTrained} icon={<TrendingUp size={12} />} />}
|
|
936
|
+
</div>
|
|
937
|
+
)}
|
|
938
|
+
|
|
939
|
+
{/* Quick stats bar for Datasets */}
|
|
940
|
+
{isDataset && datasetInfo && (
|
|
941
|
+
<div className="mt-4 flex flex-wrap gap-4">
|
|
942
|
+
{datasetInfo.samples && <QuickStat label="Samples" value={datasetInfo.samples.toLocaleString()} icon={<Database size={12} />} />}
|
|
943
|
+
{datasetInfo.features && <QuickStat label="Features" value={datasetInfo.features} icon={<Layers size={12} />} />}
|
|
944
|
+
{datasetInfo.split && <QuickStat label="Split" value={datasetInfo.split} icon={<FileText size={12} />} />}
|
|
945
|
+
{datasetInfo.source && <QuickStat label="Source" value={datasetInfo.source} icon={<Cloud size={12} />} />}
|
|
946
|
+
</div>
|
|
947
|
+
)}
|
|
762
948
|
</div>
|
|
763
949
|
|
|
950
|
+
{/* Tabs - only show for complex types */}
|
|
951
|
+
{(isModel || isDataset || isMetrics) && (
|
|
952
|
+
<div className="flex gap-1 px-6 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
|
953
|
+
<TabBtn active={activeTab === 'visualization'} onClick={() => setActiveTab('visualization')}>
|
|
954
|
+
{isModel ? <><LineChart size={14} /> Visualization</> :
|
|
955
|
+
isDataset ? <><BarChart2 size={14} /> Statistics</> :
|
|
956
|
+
<><Activity size={14} /> Metrics</>}
|
|
957
|
+
</TabBtn>
|
|
958
|
+
{displayableProps.length > 0 && (
|
|
959
|
+
<TabBtn active={activeTab === 'properties'} onClick={() => setActiveTab('properties')}>
|
|
960
|
+
<Tag size={14} /> Properties ({displayableProps.length})
|
|
961
|
+
</TabBtn>
|
|
962
|
+
)}
|
|
963
|
+
<TabBtn active={activeTab === 'metadata'} onClick={() => setActiveTab('metadata')}>
|
|
964
|
+
<Info size={14} /> Metadata
|
|
965
|
+
</TabBtn>
|
|
966
|
+
</div>
|
|
967
|
+
)}
|
|
968
|
+
|
|
764
969
|
{/* Content */}
|
|
765
|
-
<div className="
|
|
766
|
-
<
|
|
767
|
-
{/*
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
970
|
+
<div className="flex-1 overflow-y-auto p-6">
|
|
971
|
+
<AnimatePresence mode="wait">
|
|
972
|
+
{/* Visualization Tab */}
|
|
973
|
+
{activeTab === 'visualization' && (
|
|
974
|
+
<motion.div
|
|
975
|
+
key="viz"
|
|
976
|
+
initial={{ opacity: 0, y: 10 }}
|
|
977
|
+
animate={{ opacity: 1, y: 0 }}
|
|
978
|
+
exit={{ opacity: 0, y: -10 }}
|
|
979
|
+
className="space-y-6"
|
|
980
|
+
>
|
|
981
|
+
{/* Model with Training History */}
|
|
982
|
+
{isModel && effectiveTrainingHistory && (
|
|
983
|
+
<div>
|
|
984
|
+
<div className="flex items-center gap-2 mb-4">
|
|
985
|
+
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
|
986
|
+
<LineChart size={18} className="text-purple-600 dark:text-purple-400" />
|
|
987
|
+
</div>
|
|
988
|
+
<h4 className="text-lg font-bold text-slate-900 dark:text-white">Training History</h4>
|
|
989
|
+
<span className="text-xs bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded-full text-slate-500">
|
|
990
|
+
{effectiveTrainingHistory.epochs.length} epochs
|
|
991
|
+
</span>
|
|
992
|
+
</div>
|
|
993
|
+
<TrainingHistoryChart
|
|
994
|
+
trainingHistory={effectiveTrainingHistory}
|
|
995
|
+
compact={false}
|
|
996
|
+
/>
|
|
997
|
+
</div>
|
|
998
|
+
)}
|
|
999
|
+
|
|
1000
|
+
{/* Model without Training History */}
|
|
1001
|
+
{isModel && !effectiveTrainingHistory && (
|
|
1002
|
+
<div className="text-center py-12">
|
|
1003
|
+
<div className="inline-flex p-4 bg-slate-100 dark:bg-slate-800 rounded-full mb-4">
|
|
1004
|
+
<Box size={32} className="text-slate-400" />
|
|
1005
|
+
</div>
|
|
1006
|
+
<h4 className="text-lg font-bold text-slate-900 dark:text-white mb-2">Model Artifact</h4>
|
|
1007
|
+
<p className="text-slate-500 dark:text-slate-400">
|
|
1008
|
+
No training history available for this model.
|
|
1009
|
+
<br />
|
|
1010
|
+
Use <code className="bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded text-xs">FlowymlKerasCallback</code> to capture training metrics.
|
|
1011
|
+
</p>
|
|
1012
|
+
</div>
|
|
1013
|
+
)}
|
|
1014
|
+
|
|
1015
|
+
{/* Dataset Viewer - use the full DatasetViewer */}
|
|
1016
|
+
{isDataset && (
|
|
1017
|
+
<ArtifactViewer artifact={artifact} />
|
|
1018
|
+
)}
|
|
1019
|
+
|
|
1020
|
+
{/* Metrics Display */}
|
|
1021
|
+
{isMetrics && metricsData.length > 0 && (
|
|
1022
|
+
<div className="space-y-4">
|
|
1023
|
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
1024
|
+
{metricsData.map(([key, value], idx) => (
|
|
1025
|
+
<MetricDisplayCard
|
|
1026
|
+
key={key}
|
|
1027
|
+
name={key}
|
|
1028
|
+
value={value}
|
|
1029
|
+
index={idx}
|
|
1030
|
+
/>
|
|
1031
|
+
))}
|
|
1032
|
+
</div>
|
|
1033
|
+
</div>
|
|
1034
|
+
)}
|
|
1035
|
+
|
|
1036
|
+
{/* Generic Artifact */}
|
|
1037
|
+
{!isModel && !isDataset && !isMetrics && (
|
|
1038
|
+
<ArtifactViewer artifact={artifact} />
|
|
1039
|
+
)}
|
|
1040
|
+
</motion.div>
|
|
1041
|
+
)}
|
|
1042
|
+
|
|
1043
|
+
{/* Properties Tab */}
|
|
1044
|
+
{activeTab === 'properties' && (
|
|
1045
|
+
<motion.div
|
|
1046
|
+
key="props"
|
|
1047
|
+
initial={{ opacity: 0, y: 10 }}
|
|
1048
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1049
|
+
exit={{ opacity: 0, y: -10 }}
|
|
1050
|
+
>
|
|
1051
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
1052
|
+
{displayableProps.map(([key, value]) => (
|
|
1053
|
+
<div
|
|
1054
|
+
key={key}
|
|
1055
|
+
className="p-4 bg-slate-50 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700"
|
|
1056
|
+
>
|
|
1057
|
+
<span className="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide block mb-1">
|
|
1058
|
+
{key.replace(/_/g, ' ')}
|
|
1059
|
+
</span>
|
|
1060
|
+
<span className="text-sm font-mono font-semibold text-slate-900 dark:text-white break-all">
|
|
1061
|
+
{typeof value === 'object' ? JSON.stringify(value, null, 2) :
|
|
1062
|
+
typeof value === 'number' ? value.toLocaleString() :
|
|
1063
|
+
String(value)}
|
|
780
1064
|
</span>
|
|
781
1065
|
</div>
|
|
782
1066
|
))}
|
|
783
1067
|
</div>
|
|
784
|
-
</div>
|
|
1068
|
+
</motion.div>
|
|
785
1069
|
)}
|
|
786
1070
|
|
|
787
|
-
{/* Metadata */}
|
|
788
|
-
|
|
789
|
-
<
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
<
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
<div className="
|
|
801
|
-
<
|
|
802
|
-
<
|
|
1071
|
+
{/* Metadata Tab */}
|
|
1072
|
+
{activeTab === 'metadata' && (
|
|
1073
|
+
<motion.div
|
|
1074
|
+
key="meta"
|
|
1075
|
+
initial={{ opacity: 0, y: 10 }}
|
|
1076
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1077
|
+
exit={{ opacity: 0, y: -10 }}
|
|
1078
|
+
className="space-y-4"
|
|
1079
|
+
>
|
|
1080
|
+
<div className="bg-slate-50 dark:bg-slate-800 rounded-xl p-6 border border-slate-200 dark:border-slate-700">
|
|
1081
|
+
<h5 className="text-sm font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wide mb-4">
|
|
1082
|
+
Artifact Information
|
|
1083
|
+
</h5>
|
|
1084
|
+
<div className="space-y-3">
|
|
1085
|
+
<MetadataRow label="Artifact ID" value={artifact.artifact_id} mono />
|
|
1086
|
+
<MetadataRow label="Type" value={artifact.type} />
|
|
1087
|
+
<MetadataRow label="Step" value={artifact.step} />
|
|
1088
|
+
{artifact.run_id && <MetadataRow label="Run ID" value={artifact.run_id} mono />}
|
|
1089
|
+
{artifact.pipeline_name && <MetadataRow label="Pipeline" value={artifact.pipeline_name} />}
|
|
1090
|
+
{artifact.created_at && (
|
|
1091
|
+
<MetadataRow
|
|
1092
|
+
label="Created"
|
|
1093
|
+
value={format(new Date(artifact.created_at), 'MMM d, yyyy HH:mm:ss')}
|
|
1094
|
+
/>
|
|
1095
|
+
)}
|
|
803
1096
|
</div>
|
|
804
|
-
|
|
805
|
-
</div>
|
|
806
|
-
|
|
807
|
-
</
|
|
1097
|
+
</div>
|
|
1098
|
+
</motion.div>
|
|
1099
|
+
)}
|
|
1100
|
+
</AnimatePresence>
|
|
808
1101
|
</div>
|
|
809
1102
|
|
|
810
1103
|
{/* Footer */}
|
|
811
|
-
<div className="p-4 border-t border-slate-
|
|
812
|
-
<
|
|
813
|
-
|
|
1104
|
+
<div className="p-4 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 flex justify-between items-center">
|
|
1105
|
+
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
1106
|
+
💡 Click Download to save the full artifact data
|
|
1107
|
+
</p>
|
|
1108
|
+
<div className="flex gap-2">
|
|
1109
|
+
<Button variant="ghost" onClick={onClose}>Close</Button>
|
|
1110
|
+
<Button
|
|
1111
|
+
variant="primary"
|
|
1112
|
+
onClick={() => downloadArtifactById(artifact.artifact_id)}
|
|
1113
|
+
disabled={!artifact.artifact_id}
|
|
1114
|
+
>
|
|
1115
|
+
<Download size={14} className="mr-1.5" />
|
|
1116
|
+
Download
|
|
1117
|
+
</Button>
|
|
1118
|
+
</div>
|
|
814
1119
|
</div>
|
|
815
1120
|
</motion.div>
|
|
816
1121
|
</motion.div>
|
|
@@ -818,6 +1123,105 @@ function ArtifactModal({ artifact, onClose }) {
|
|
|
818
1123
|
);
|
|
819
1124
|
}
|
|
820
1125
|
|
|
1126
|
+
// Quick stat component for header
|
|
1127
|
+
function QuickStat({ label, value, icon }) {
|
|
1128
|
+
return (
|
|
1129
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/50 dark:bg-slate-800/50 rounded-lg backdrop-blur-sm border border-slate-200/50 dark:border-slate-700/50">
|
|
1130
|
+
<span className="text-slate-400">{icon}</span>
|
|
1131
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">{label}:</span>
|
|
1132
|
+
<span className="text-sm font-bold text-slate-900 dark:text-white">{value}</span>
|
|
1133
|
+
</div>
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Tab button component
|
|
1138
|
+
function TabBtn({ active, onClick, children }) {
|
|
1139
|
+
return (
|
|
1140
|
+
<button
|
|
1141
|
+
onClick={onClick}
|
|
1142
|
+
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
|
1143
|
+
active
|
|
1144
|
+
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm'
|
|
1145
|
+
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-white/50 dark:hover:bg-slate-700/50'
|
|
1146
|
+
}`}
|
|
1147
|
+
>
|
|
1148
|
+
{children}
|
|
1149
|
+
</button>
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Metric display card for Metrics artifacts
|
|
1154
|
+
function MetricDisplayCard({ name, value, index }) {
|
|
1155
|
+
const colors = [
|
|
1156
|
+
'from-blue-500 to-indigo-600',
|
|
1157
|
+
'from-purple-500 to-violet-600',
|
|
1158
|
+
'from-emerald-500 to-teal-600',
|
|
1159
|
+
'from-amber-500 to-orange-600',
|
|
1160
|
+
'from-rose-500 to-pink-600',
|
|
1161
|
+
'from-cyan-500 to-blue-600',
|
|
1162
|
+
];
|
|
1163
|
+
const color = colors[index % colors.length];
|
|
1164
|
+
|
|
1165
|
+
// Format name nicely
|
|
1166
|
+
const displayName = name
|
|
1167
|
+
.replace(/_/g, ' ')
|
|
1168
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
1169
|
+
|
|
1170
|
+
// Format value
|
|
1171
|
+
const displayValue = typeof value === 'number'
|
|
1172
|
+
? (Math.abs(value) < 0.01 || Math.abs(value) > 1000
|
|
1173
|
+
? value.toExponential(3)
|
|
1174
|
+
: value.toFixed(4))
|
|
1175
|
+
: value;
|
|
1176
|
+
|
|
1177
|
+
// Determine if this is a "good" metric (accuracy, score) or "bad" (loss, error)
|
|
1178
|
+
const isLossLike = name.toLowerCase().includes('loss') ||
|
|
1179
|
+
name.toLowerCase().includes('error') ||
|
|
1180
|
+
name.toLowerCase().includes('mse') ||
|
|
1181
|
+
name.toLowerCase().includes('mae');
|
|
1182
|
+
|
|
1183
|
+
return (
|
|
1184
|
+
<motion.div
|
|
1185
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
1186
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
1187
|
+
transition={{ delay: index * 0.05 }}
|
|
1188
|
+
className="relative overflow-hidden p-4 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 hover:shadow-lg transition-all group"
|
|
1189
|
+
>
|
|
1190
|
+
<div className={`absolute top-0 right-0 w-16 h-16 bg-gradient-to-br ${color} opacity-10 rounded-full blur-2xl group-hover:opacity-20 transition-opacity`} />
|
|
1191
|
+
<div className="relative">
|
|
1192
|
+
<p className="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1 truncate">
|
|
1193
|
+
{displayName}
|
|
1194
|
+
</p>
|
|
1195
|
+
<p className="text-2xl font-bold font-mono text-slate-900 dark:text-white">
|
|
1196
|
+
{displayValue}
|
|
1197
|
+
</p>
|
|
1198
|
+
<div className="mt-1 flex items-center gap-1">
|
|
1199
|
+
{isLossLike ? (
|
|
1200
|
+
<TrendingDown size={12} className="text-emerald-500" />
|
|
1201
|
+
) : (
|
|
1202
|
+
<TrendingUp size={12} className="text-emerald-500" />
|
|
1203
|
+
)}
|
|
1204
|
+
<span className="text-xs text-slate-400">
|
|
1205
|
+
{isLossLike ? 'Lower is better' : 'Higher is better'}
|
|
1206
|
+
</span>
|
|
1207
|
+
</div>
|
|
1208
|
+
</div>
|
|
1209
|
+
</motion.div>
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Metadata row component
|
|
1214
|
+
function MetadataRow({ label, value, mono = false }) {
|
|
1215
|
+
return (
|
|
1216
|
+
<div className="flex justify-between items-center py-2 border-b border-slate-200/50 dark:border-slate-700/50 last:border-0">
|
|
1217
|
+
<span className="text-sm text-slate-500 dark:text-slate-400">{label}</span>
|
|
1218
|
+
<span className={`text-sm font-medium text-slate-900 dark:text-white ${mono ? 'font-mono text-xs' : ''}`}>
|
|
1219
|
+
{value || '—'}
|
|
1220
|
+
</span>
|
|
1221
|
+
</div>
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
821
1225
|
function LogsViewer({ runId, stepName, isRunning, maxHeight = "max-h-96", minimal = false }) {
|
|
822
1226
|
const [logs, setLogs] = useState('');
|
|
823
1227
|
const [loading, setLoading] = useState(true);
|
|
@@ -964,3 +1368,87 @@ function LogsViewer({ runId, stepName, isRunning, maxHeight = "max-h-96", minima
|
|
|
964
1368
|
</div>
|
|
965
1369
|
);
|
|
966
1370
|
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Training Metrics Section Wrapper
|
|
1374
|
+
* Only renders the training metrics section if there's actual training data.
|
|
1375
|
+
* This prevents showing an empty section for non-training pipelines.
|
|
1376
|
+
*/
|
|
1377
|
+
function TrainingMetricsSectionWrapper({ runId, isRunning }) {
|
|
1378
|
+
const [hasData, setHasData] = useState(false);
|
|
1379
|
+
const [isExpanded, setIsExpanded] = useState(true);
|
|
1380
|
+
const [loading, setLoading] = useState(true);
|
|
1381
|
+
|
|
1382
|
+
// Check if training data exists
|
|
1383
|
+
useEffect(() => {
|
|
1384
|
+
const checkTrainingData = async () => {
|
|
1385
|
+
try {
|
|
1386
|
+
const res = await fetchApi(`/api/runs/${runId}/training-history`);
|
|
1387
|
+
if (res.ok) {
|
|
1388
|
+
const data = await res.json();
|
|
1389
|
+
setHasData(data.has_history && data.training_history?.epochs?.length > 0);
|
|
1390
|
+
}
|
|
1391
|
+
} catch (err) {
|
|
1392
|
+
console.error('Error checking training history:', err);
|
|
1393
|
+
} finally {
|
|
1394
|
+
setLoading(false);
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
if (runId) {
|
|
1399
|
+
checkTrainingData();
|
|
1400
|
+
}
|
|
1401
|
+
}, [runId]);
|
|
1402
|
+
|
|
1403
|
+
// Don't render anything if no training data
|
|
1404
|
+
if (loading) return null;
|
|
1405
|
+
if (!hasData) return null;
|
|
1406
|
+
|
|
1407
|
+
return (
|
|
1408
|
+
<div className="mt-8">
|
|
1409
|
+
<Card className="overflow-hidden">
|
|
1410
|
+
<div
|
|
1411
|
+
className="p-6 cursor-pointer flex items-center justify-between bg-gradient-to-r from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 border-b border-slate-100 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
|
1412
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
1413
|
+
>
|
|
1414
|
+
<div className="flex items-center gap-3">
|
|
1415
|
+
<div className="p-2.5 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl shadow-lg">
|
|
1416
|
+
<LineChart size={20} className="text-white" />
|
|
1417
|
+
</div>
|
|
1418
|
+
<div>
|
|
1419
|
+
<h3 className="text-lg font-bold text-slate-900 dark:text-white">
|
|
1420
|
+
Training Metrics
|
|
1421
|
+
</h3>
|
|
1422
|
+
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
1423
|
+
Interactive training history visualization
|
|
1424
|
+
</p>
|
|
1425
|
+
</div>
|
|
1426
|
+
</div>
|
|
1427
|
+
<ChevronRight
|
|
1428
|
+
size={20}
|
|
1429
|
+
className={`text-slate-400 transform transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
|
1430
|
+
/>
|
|
1431
|
+
</div>
|
|
1432
|
+
<AnimatePresence>
|
|
1433
|
+
{isExpanded && (
|
|
1434
|
+
<motion.div
|
|
1435
|
+
initial={{ height: 0, opacity: 0 }}
|
|
1436
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
1437
|
+
exit={{ height: 0, opacity: 0 }}
|
|
1438
|
+
transition={{ duration: 0.2 }}
|
|
1439
|
+
className="overflow-hidden"
|
|
1440
|
+
>
|
|
1441
|
+
<div className="p-6">
|
|
1442
|
+
<TrainingMetricsPanel
|
|
1443
|
+
runId={runId}
|
|
1444
|
+
isRunning={isRunning}
|
|
1445
|
+
autoRefresh={true}
|
|
1446
|
+
/>
|
|
1447
|
+
</div>
|
|
1448
|
+
</motion.div>
|
|
1449
|
+
)}
|
|
1450
|
+
</AnimatePresence>
|
|
1451
|
+
</Card>
|
|
1452
|
+
</div>
|
|
1453
|
+
);
|
|
1454
|
+
}
|