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.
Files changed (30) 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/ui/backend/routers/runs.py +112 -0
  9. flowyml/ui/backend/routers/schedules.py +35 -15
  10. flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +1 -0
  11. flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +685 -0
  12. flowyml/ui/frontend/dist/index.html +2 -2
  13. flowyml/ui/frontend/package-lock.json +11 -0
  14. flowyml/ui/frontend/package.json +1 -0
  15. flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
  16. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
  17. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +589 -101
  18. flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
  19. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +401 -28
  20. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
  21. flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
  22. flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
  23. flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
  24. {flowyml-1.7.1.dist-info → flowyml-1.7.2.dist-info}/METADATA +1 -1
  25. {flowyml-1.7.1.dist-info → flowyml-1.7.2.dist-info}/RECORD +28 -25
  26. flowyml/ui/frontend/dist/assets/index-BqDQvp63.js +0 -630
  27. flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
  28. {flowyml-1.7.1.dist-info → flowyml-1.7.2.dist-info}/WHEEL +0 -0
  29. {flowyml-1.7.1.dist-info → flowyml-1.7.2.dist-info}/entry_points.txt +0 -0
  30. {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="grid grid-cols-1 lg:grid-cols-3 gap-6">
232
- {/* DAG Visualization - 2 columns */}
233
- <div className="lg:col-span-2">
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="h-[calc(100vh-240px)] min-h-[600px]">
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={(name) => {
245
- // Find the artifact object by name
246
- // We look in 'artifacts' array which contains all assets for the run
247
- const found = artifacts.find(a => a.name === name);
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
- // Fallback if not found (e.g. might be an intermediate artifact not persisted)
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 - 1 column */}
266
- <div>
267
- <h3 className="text-xl font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
268
- <Info className="text-primary-500" /> Step Details
269
- </h3>
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="overflow-hidden">
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
- <h4 className="text-lg font-bold text-slate-900 dark:text-white mb-2">{selectedStep}</h4>
276
- <div className="flex items-center gap-2">
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 (Header) */}
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-2 border-b border-slate-100 dark:border-slate-700 mt-4">
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={16} /> Overview
374
+ <Info size={14} /> Overview
318
375
  </TabButton>
319
376
  <TabButton active={activeTab === 'code'} onClick={() => setActiveTab('code')}>
320
- <Code2 size={16} /> Code
377
+ <Code2 size={14} /> Code
321
378
  </TabButton>
322
379
  <TabButton active={activeTab === 'artifacts'} onClick={() => setActiveTab('artifacts')}>
323
- <Package size={16} /> Artifacts
380
+ <Package size={14} /> Artifacts
324
381
  </TabButton>
325
382
  <TabButton active={activeTab === 'logs'} onClick={() => setActiveTab('logs')} data-tab="logs">
326
- <Terminal size={16} /> Logs
383
+ <Terminal size={14} /> Logs
327
384
  </TabButton>
328
385
  </div>
329
386
 
330
- {/* Tab Content */}
331
- <div className="mt-4 max-h-[450px] overflow-y-auto">
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
- <p className="text-slate-500">Select a step to view details</p>
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/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
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 max-w-3xl w-full max-h-[80vh] overflow-hidden"
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="flex items-center justify-between p-6 border-b border-slate-100 bg-gradient-to-r from-primary-50 to-purple-50">
735
- <div className="flex items-center gap-3">
736
- <div className="p-3 bg-white rounded-xl shadow-sm">
737
- {artifact.type === 'Dataset' ? <Database size={24} className="text-blue-600" /> :
738
- artifact.type === 'Model' ? <Box size={24} className="text-purple-600" /> :
739
- artifact.type === 'Metrics' ? <BarChart2 size={24} className="text-emerald-600" /> :
740
- <FileText size={24} className="text-slate-600" />}
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
- <h3 className="text-xl font-bold text-slate-900">{artifact.name}</h3>
744
- <p className="text-sm text-slate-500">{artifact.type}</p>
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
- <div className="flex items-center gap-2">
748
- <button
749
- onClick={() => downloadArtifactById(artifact.artifact_id)}
750
- className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-500 transition-colors disabled:opacity-50"
751
- disabled={!artifact.artifact_id}
752
- >
753
- <Download size={16} /> Download
754
- </button>
755
- <button
756
- onClick={onClose}
757
- className="p-2 hover:bg-white rounded-lg transition-colors"
758
- >
759
- <X size={20} className="text-slate-400" />
760
- </button>
761
- </div>
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="p-6 overflow-y-auto max-h-[60vh]">
766
- <div className="space-y-4">
767
- {/* Rich Viewer */}
768
- <ArtifactViewer artifact={artifact} />
769
-
770
- {/* Properties (collapsible or below) */}
771
- {artifact.properties && Object.keys(artifact.properties).length > 0 && (
772
- <div className="mt-6 pt-6 border-t border-slate-100">
773
- <h4 className="text-sm font-semibold text-slate-700 mb-3">Properties</h4>
774
- <div className="grid grid-cols-2 gap-3">
775
- {Object.entries(artifact.properties).map(([key, value]) => (
776
- <div key={key} className="p-3 bg-slate-50 rounded-lg border border-slate-100">
777
- <span className="text-xs text-slate-500 block mb-1">{key}</span>
778
- <span className="text-sm font-mono font-semibold text-slate-900">
779
- {typeof value === 'object' ? JSON.stringify(value) : String(value)}
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
- <div className="mt-6">
789
- <h4 className="text-sm font-semibold text-slate-700 mb-3">Metadata</h4>
790
- <div className="space-y-2 text-sm">
791
- <div className="flex justify-between">
792
- <span className="text-slate-500">Artifact ID:</span>
793
- <span className="font-mono text-xs text-slate-700">{artifact.artifact_id}</span>
794
- </div>
795
- <div className="flex justify-between">
796
- <span className="text-slate-500">Step:</span>
797
- <span className="font-medium text-slate-700">{artifact.step}</span>
798
- </div>
799
- {artifact.created_at && (
800
- <div className="flex justify-between">
801
- <span className="text-slate-500">Created:</span>
802
- <span className="text-slate-700">{format(new Date(artifact.created_at), 'MMM d, yyyy HH:mm:ss')}</span>
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
- </div>
807
- </div>
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-100 bg-slate-50 flex justify-end gap-2">
812
- <Button variant="ghost" onClick={onClose}>Close</Button>
813
- <Button variant="primary">Download</Button>
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
+ }