flowyml 1.7.1__py3-none-any.whl → 1.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. flowyml/assets/base.py +15 -0
  2. flowyml/assets/dataset.py +570 -17
  3. flowyml/assets/metrics.py +5 -0
  4. flowyml/assets/model.py +1052 -15
  5. flowyml/cli/main.py +709 -0
  6. flowyml/cli/stack_cli.py +138 -25
  7. flowyml/core/__init__.py +17 -0
  8. flowyml/core/executor.py +231 -37
  9. flowyml/core/image_builder.py +129 -0
  10. flowyml/core/log_streamer.py +227 -0
  11. flowyml/core/orchestrator.py +59 -4
  12. flowyml/core/pipeline.py +65 -13
  13. flowyml/core/routing.py +558 -0
  14. flowyml/core/scheduler.py +88 -5
  15. flowyml/core/step.py +9 -1
  16. flowyml/core/step_grouping.py +49 -35
  17. flowyml/core/types.py +407 -0
  18. flowyml/integrations/keras.py +247 -82
  19. flowyml/monitoring/alerts.py +10 -0
  20. flowyml/monitoring/notifications.py +104 -25
  21. flowyml/monitoring/slack_blocks.py +323 -0
  22. flowyml/plugins/__init__.py +251 -0
  23. flowyml/plugins/alerters/__init__.py +1 -0
  24. flowyml/plugins/alerters/slack.py +168 -0
  25. flowyml/plugins/base.py +752 -0
  26. flowyml/plugins/config.py +478 -0
  27. flowyml/plugins/deployers/__init__.py +22 -0
  28. flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
  29. flowyml/plugins/deployers/sagemaker.py +306 -0
  30. flowyml/plugins/deployers/vertex.py +290 -0
  31. flowyml/plugins/integration.py +369 -0
  32. flowyml/plugins/manager.py +510 -0
  33. flowyml/plugins/model_registries/__init__.py +22 -0
  34. flowyml/plugins/model_registries/mlflow.py +159 -0
  35. flowyml/plugins/model_registries/sagemaker.py +489 -0
  36. flowyml/plugins/model_registries/vertex.py +386 -0
  37. flowyml/plugins/orchestrators/__init__.py +13 -0
  38. flowyml/plugins/orchestrators/sagemaker.py +443 -0
  39. flowyml/plugins/orchestrators/vertex_ai.py +461 -0
  40. flowyml/plugins/registries/__init__.py +13 -0
  41. flowyml/plugins/registries/ecr.py +321 -0
  42. flowyml/plugins/registries/gcr.py +313 -0
  43. flowyml/plugins/registry.py +454 -0
  44. flowyml/plugins/stack.py +494 -0
  45. flowyml/plugins/stack_config.py +537 -0
  46. flowyml/plugins/stores/__init__.py +13 -0
  47. flowyml/plugins/stores/gcs.py +460 -0
  48. flowyml/plugins/stores/s3.py +453 -0
  49. flowyml/plugins/trackers/__init__.py +11 -0
  50. flowyml/plugins/trackers/mlflow.py +316 -0
  51. flowyml/plugins/validators/__init__.py +3 -0
  52. flowyml/plugins/validators/deepchecks.py +119 -0
  53. flowyml/registry/__init__.py +2 -1
  54. flowyml/registry/model_environment.py +109 -0
  55. flowyml/registry/model_registry.py +241 -96
  56. flowyml/serving/__init__.py +17 -0
  57. flowyml/serving/model_server.py +628 -0
  58. flowyml/stacks/__init__.py +60 -0
  59. flowyml/stacks/aws.py +93 -0
  60. flowyml/stacks/base.py +62 -0
  61. flowyml/stacks/components.py +12 -0
  62. flowyml/stacks/gcp.py +44 -9
  63. flowyml/stacks/plugins.py +115 -0
  64. flowyml/stacks/registry.py +2 -1
  65. flowyml/storage/sql.py +401 -12
  66. flowyml/tracking/experiment.py +8 -5
  67. flowyml/ui/backend/Dockerfile +87 -16
  68. flowyml/ui/backend/auth.py +12 -2
  69. flowyml/ui/backend/main.py +149 -5
  70. flowyml/ui/backend/routers/ai_context.py +226 -0
  71. flowyml/ui/backend/routers/assets.py +23 -4
  72. flowyml/ui/backend/routers/auth.py +96 -0
  73. flowyml/ui/backend/routers/deployments.py +660 -0
  74. flowyml/ui/backend/routers/model_explorer.py +597 -0
  75. flowyml/ui/backend/routers/plugins.py +103 -51
  76. flowyml/ui/backend/routers/projects.py +91 -8
  77. flowyml/ui/backend/routers/runs.py +132 -1
  78. flowyml/ui/backend/routers/schedules.py +54 -29
  79. flowyml/ui/backend/routers/templates.py +319 -0
  80. flowyml/ui/backend/routers/websocket.py +2 -2
  81. flowyml/ui/frontend/Dockerfile +55 -6
  82. flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
  83. flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
  84. flowyml/ui/frontend/dist/index.html +2 -2
  85. flowyml/ui/frontend/dist/logo.png +0 -0
  86. flowyml/ui/frontend/nginx.conf +65 -4
  87. flowyml/ui/frontend/package-lock.json +1415 -74
  88. flowyml/ui/frontend/package.json +4 -0
  89. flowyml/ui/frontend/public/logo.png +0 -0
  90. flowyml/ui/frontend/src/App.jsx +10 -7
  91. flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
  92. flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
  93. flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
  94. flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
  95. flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
  96. flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
  97. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
  98. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
  99. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +601 -101
  100. flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
  101. flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
  102. flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
  103. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +424 -29
  104. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
  105. flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
  106. flowyml/ui/frontend/src/components/Layout.jsx +6 -0
  107. flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
  108. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
  109. flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
  110. flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
  111. flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
  112. flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
  113. flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
  114. flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
  115. flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
  116. flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
  117. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
  118. flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
  119. flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
  120. flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
  121. flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
  122. flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
  123. flowyml/ui/frontend/src/router/index.jsx +47 -20
  124. flowyml/ui/frontend/src/services/pluginService.js +3 -1
  125. flowyml/ui/server_manager.py +5 -5
  126. flowyml/ui/utils.py +157 -39
  127. flowyml/utils/config.py +37 -15
  128. flowyml/utils/model_introspection.py +123 -0
  129. flowyml/utils/observability.py +30 -0
  130. flowyml-1.8.0.dist-info/METADATA +174 -0
  131. {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/RECORD +134 -73
  132. {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
  133. flowyml/ui/frontend/dist/assets/index-BqDQvp63.js +0 -630
  134. flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
  135. flowyml-1.7.1.dist-info/METADATA +0 -477
  136. {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
  137. {flowyml-1.7.1.dist-info → flowyml-1.8.0.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,10 @@ 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';
17
+ import { RunMetaPanel } from '../../../components/RunMetaPanel';
18
+ import { useAIContext } from '../../../hooks/useAIContext';
15
19
 
16
20
  export function RunDetails() {
17
21
  const { runId } = useParams();
@@ -26,6 +30,17 @@ export function RunDetails() {
26
30
  const [isPolling, setIsPolling] = useState(false);
27
31
  const [stopping, setStopping] = useState(false);
28
32
  const [stepLogs, setStepLogs] = useState({});
33
+ const [stepPanelExpanded, setStepPanelExpanded] = useState(false);
34
+ const [hasTrainingHistory, setHasTrainingHistory] = useState(false);
35
+
36
+ // Share run context with AI assistant
37
+ useAIContext({
38
+ pageType: 'run',
39
+ resourceId: runId,
40
+ includeLogs: true,
41
+ includeCode: true,
42
+ includeMetrics: true
43
+ });
29
44
 
30
45
  const handleStopRun = async () => {
31
46
  if (!confirm('Are you sure you want to stop this run?')) return;
@@ -227,30 +242,74 @@ export function RunDetails() {
227
242
  />
228
243
  </div>
229
244
 
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">
245
+ {/* Environment & Metadata Panel */}
246
+ <RunMetaPanel run={run} />
247
+
248
+ {/* Main Content - Dynamic Split View */}
249
+
250
+ {/* Main Content - Dynamic Split View */}
251
+ <div className={`grid gap-6 transition-all duration-300 ${stepPanelExpanded
252
+ ? 'grid-cols-1 lg:grid-cols-2'
253
+ : 'grid-cols-1 lg:grid-cols-3'
254
+ }`}>
255
+ {/* DAG Visualization */}
256
+ <div className={stepPanelExpanded ? 'lg:col-span-1' : 'lg:col-span-2'}>
234
257
  <h3 className="text-xl font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
235
258
  <Activity className="text-primary-500" /> Pipeline Execution Graph
236
259
  </h3>
237
- <div className="h-[calc(100vh-240px)] min-h-[600px]">
260
+ <div className={`min-h-[500px] ${stepPanelExpanded ? 'h-[500px]' : 'h-[calc(100vh-240px)]'}`}>
238
261
  {run.dag ? (
239
262
  <PipelineGraph
240
263
  dag={run.dag}
241
264
  steps={run.steps}
242
265
  selectedStep={selectedStep}
243
266
  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);
267
+ onArtifactSelect={(artifactLabel) => {
268
+ // Smart artifact matching:
269
+ // DAG labels are like "data/train", "model/trained"
270
+ // API names are like "train_dataset", "trained_model"
271
+
272
+ // 1. Exact name match
273
+ let found = artifacts.find(a => a.name === artifactLabel);
274
+
275
+ if (!found) {
276
+ // 2. Match by output pattern
277
+ // "data/train" -> look for artifacts with "train" and type Dataset
278
+ // "model/trained" -> look for artifacts with "trained" and type Model
279
+ const parts = artifactLabel.split('/');
280
+ const prefix = parts[0]; // "data", "model", "metrics"
281
+ const suffix = parts[1]; // "train", "trained", "evaluation"
282
+
283
+ const typeMap = {
284
+ 'data': 'Dataset',
285
+ 'model': 'Model',
286
+ 'metrics': 'Metrics',
287
+ 'features': 'FeatureSet',
288
+ };
289
+ const expectedType = typeMap[prefix];
290
+
291
+ // Look for artifacts with matching type and suffix in name
292
+ if (suffix && expectedType) {
293
+ found = artifacts.find(a =>
294
+ a.type === expectedType &&
295
+ (a.name.toLowerCase().includes(suffix.toLowerCase()) ||
296
+ suffix.toLowerCase().includes(a.name.toLowerCase().replace(/_/g, '')))
297
+ );
298
+ }
299
+
300
+ // 3. Fallback: match by type alone if only one of that type
301
+ if (!found && expectedType) {
302
+ const typeMatches = artifacts.filter(a => a.type === expectedType);
303
+ if (typeMatches.length === 1) {
304
+ found = typeMatches[0];
305
+ }
306
+ }
307
+ }
308
+
248
309
  if (found) {
249
310
  setSelectedArtifact(found);
250
311
  } 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
312
+ console.warn(`Artifact "${artifactLabel}" not found in assets list. Available:`, artifacts.map(a => a.name));
254
313
  }
255
314
  }}
256
315
  />
@@ -262,18 +321,30 @@ export function RunDetails() {
262
321
  </div>
263
322
  </div>
264
323
 
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>
324
+ {/* Step Details Panel - Expandable */}
325
+ <div className={stepPanelExpanded ? 'lg:col-span-1' : ''}>
326
+ <div className="flex items-center justify-between mb-4">
327
+ <h3 className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
328
+ <Info className="text-primary-500" /> Step Details
329
+ </h3>
330
+ <button
331
+ onClick={() => setStepPanelExpanded(!stepPanelExpanded)}
332
+ className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
333
+ title={stepPanelExpanded ? 'Collapse panel' : 'Expand panel'}
334
+ >
335
+ <Maximize2 size={16} className="text-slate-600 dark:text-slate-300" />
336
+ </button>
337
+ </div>
270
338
 
271
339
  {selectedStepData ? (
272
- <Card className="overflow-hidden">
340
+ <Card className={`overflow-hidden transition-all duration-300 ${stepPanelExpanded ? 'h-auto' : ''
341
+ }`}>
273
342
  {/* Step Header */}
274
343
  <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">
344
+ <div className="flex items-center justify-between">
345
+ <h4 className="text-lg font-bold text-slate-900 dark:text-white">{selectedStep}</h4>
346
+ </div>
347
+ <div className="flex items-center gap-2 mt-2">
277
348
  <Badge variant={selectedStepData.success ? 'success' : 'danger'} className="text-xs">
278
349
  {selectedStepData.success ? 'Success' : 'Failed'}
279
350
  </Badge>
@@ -288,7 +359,7 @@ export function RunDetails() {
288
359
  </div>
289
360
  </div>
290
361
 
291
- {/* Live Heartbeat Indicator (Header) */}
362
+ {/* Live Heartbeat Indicator */}
292
363
  {selectedStepData.status === 'running' && (
293
364
  <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
365
  <div className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-300">
@@ -312,23 +383,24 @@ export function RunDetails() {
312
383
  )}
313
384
 
314
385
  {/* Tabs */}
315
- <div className="flex gap-2 border-b border-slate-100 dark:border-slate-700 mt-4">
386
+ <div className="flex gap-1 border-b border-slate-100 dark:border-slate-700 mt-4 overflow-x-auto">
316
387
  <TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')}>
317
- <Info size={16} /> Overview
388
+ <Info size={14} /> Overview
318
389
  </TabButton>
319
390
  <TabButton active={activeTab === 'code'} onClick={() => setActiveTab('code')}>
320
- <Code2 size={16} /> Code
391
+ <Code2 size={14} /> Code
321
392
  </TabButton>
322
393
  <TabButton active={activeTab === 'artifacts'} onClick={() => setActiveTab('artifacts')}>
323
- <Package size={16} /> Artifacts
394
+ <Package size={14} /> Artifacts
324
395
  </TabButton>
325
396
  <TabButton active={activeTab === 'logs'} onClick={() => setActiveTab('logs')} data-tab="logs">
326
- <Terminal size={16} /> Logs
397
+ <Terminal size={14} /> Logs
327
398
  </TabButton>
328
399
  </div>
329
400
 
330
- {/* Tab Content */}
331
- <div className="mt-4 max-h-[450px] overflow-y-auto">
401
+ {/* Tab Content - Larger when expanded */}
402
+ <div className={`mt-4 overflow-y-auto transition-all duration-300 ${stepPanelExpanded ? 'max-h-[600px]' : 'max-h-[450px]'
403
+ }`}>
332
404
  {activeTab === 'overview' && (
333
405
  <OverviewTab
334
406
  stepData={selectedStepData}
@@ -351,18 +423,27 @@ export function RunDetails() {
351
423
  runId={runId}
352
424
  stepName={selectedStep}
353
425
  isRunning={run.status === 'running'}
426
+ maxHeight={stepPanelExpanded ? 'max-h-[500px]' : 'max-h-96'}
354
427
  />
355
428
  )}
356
429
  </div>
357
430
  </Card>
358
431
  ) : (
359
- <Card className="p-12 text-center">
360
- <p className="text-slate-500">Select a step to view details</p>
432
+ <Card className="p-12 text-center border-2 border-dashed border-slate-200 dark:border-slate-700">
433
+ <Info size={32} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
434
+ <p className="text-slate-500 dark:text-slate-400 font-medium">Select a step to view details</p>
435
+ <p className="text-xs text-slate-400 dark:text-slate-500 mt-1">Click on any step in the graph</p>
361
436
  </Card>
362
437
  )}
363
438
  </div>
364
439
  </div>
365
440
 
441
+ {/* Training Metrics Section - Only shown if there's training data */}
442
+ <TrainingMetricsSectionWrapper
443
+ runId={runId}
444
+ isRunning={run.status === 'running'}
445
+ />
446
+
366
447
  {/* Artifact Detail Modal */}
367
448
  <ArtifactModal
368
449
  artifact={selectedArtifact}
@@ -712,105 +793,342 @@ function ArtifactsTab({ artifacts, onArtifactClick }) {
712
793
  }
713
794
 
714
795
  function ArtifactModal({ artifact, onClose }) {
796
+ const [activeTab, setActiveTab] = useState('visualization');
797
+
715
798
  if (!artifact) return null;
716
799
 
800
+ const isDataset = artifact.type === 'Dataset' || artifact.asset_type === 'Dataset';
801
+ const isModel = artifact.type === 'Model' || artifact.asset_type === 'Model';
802
+ const isMetrics = artifact.type === 'Metrics' || artifact.asset_type === 'Metrics';
803
+
804
+ // Get properties for display (filter out internal/large ones)
805
+ const displayableProps = artifact.properties
806
+ ? Object.entries(artifact.properties).filter(([key, value]) => {
807
+ // Skip internal properties and very large values
808
+ if (key.startsWith('_')) return false;
809
+ if (typeof value === 'string' && value.length > 500) return false;
810
+ return true;
811
+ })
812
+ : [];
813
+
814
+ // Check for training history
815
+ const hasTrainingHistory = artifact.training_history &&
816
+ artifact.training_history.epochs &&
817
+ artifact.training_history.epochs.length > 0;
818
+
819
+ // Also check in properties for training history
820
+ const trainingHistoryFromProps = artifact.properties?.training_history;
821
+ const effectiveTrainingHistory = artifact.training_history || (
822
+ trainingHistoryFromProps && trainingHistoryFromProps.epochs
823
+ ? trainingHistoryFromProps
824
+ : null
825
+ );
826
+
827
+ // Get icon and gradient based on type
828
+ const getTypeStyle = () => {
829
+ if (isDataset) return {
830
+ icon: <Database size={28} />,
831
+ gradient: 'from-blue-500 to-indigo-600',
832
+ bgGradient: 'from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20',
833
+ iconBg: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
834
+ };
835
+ if (isModel) return {
836
+ icon: <Box size={28} />,
837
+ gradient: 'from-purple-500 to-violet-600',
838
+ bgGradient: 'from-purple-50 to-violet-50 dark:from-purple-900/20 dark:to-violet-900/20',
839
+ iconBg: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
840
+ };
841
+ if (isMetrics) return {
842
+ icon: <BarChart2 size={28} />,
843
+ gradient: 'from-emerald-500 to-teal-600',
844
+ bgGradient: 'from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20',
845
+ iconBg: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400'
846
+ };
847
+ return {
848
+ icon: <FileText size={28} />,
849
+ gradient: 'from-slate-500 to-slate-600',
850
+ bgGradient: 'from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-900',
851
+ iconBg: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
852
+ };
853
+ };
854
+
855
+ const typeStyle = getTypeStyle();
856
+
857
+ // Get model-specific properties
858
+ const modelInfo = isModel ? {
859
+ framework: artifact.properties?.framework || 'Unknown',
860
+ parameters: artifact.properties?.parameters,
861
+ layers: artifact.properties?.num_layers,
862
+ optimizer: artifact.properties?.optimizer,
863
+ learningRate: artifact.properties?.learning_rate,
864
+ epochsTrained: artifact.properties?.epochs_trained || effectiveTrainingHistory?.epochs?.length,
865
+ } : null;
866
+
867
+ // Get dataset-specific properties
868
+ const datasetInfo = isDataset ? {
869
+ samples: artifact.properties?.num_samples || artifact.properties?.samples,
870
+ features: artifact.properties?.num_features,
871
+ source: artifact.properties?.source,
872
+ split: artifact.properties?.split,
873
+ } : null;
874
+
875
+ // Get metrics-specific data
876
+ const metricsData = isMetrics && artifact.properties ? Object.entries(artifact.properties)
877
+ .filter(([key, value]) => typeof value === 'number' && !key.startsWith('_'))
878
+ .sort((a, b) => a[0].localeCompare(b[0])) : [];
879
+
717
880
  return (
718
881
  <AnimatePresence>
719
882
  <motion.div
720
883
  initial={{ opacity: 0 }}
721
884
  animate={{ opacity: 1 }}
722
885
  exit={{ opacity: 0 }}
723
- className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
886
+ className="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4"
724
887
  onClick={onClose}
725
888
  >
726
889
  <motion.div
727
- initial={{ scale: 0.9, opacity: 0 }}
728
- animate={{ scale: 1, opacity: 1 }}
729
- exit={{ scale: 0.9, opacity: 0 }}
890
+ initial={{ scale: 0.9, opacity: 0, y: 20 }}
891
+ animate={{ scale: 1, opacity: 1, y: 0 }}
892
+ exit={{ scale: 0.9, opacity: 0, y: 20 }}
893
+ transition={{ type: 'spring', damping: 25, stiffness: 300 }}
730
894
  onClick={(e) => e.stopPropagation()}
731
- className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[80vh] overflow-hidden"
895
+ 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
896
  >
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" />}
897
+ {/* Header with gradient */}
898
+ <div className={`relative p-6 bg-gradient-to-r ${typeStyle.bgGradient} border-b border-slate-200 dark:border-slate-700`}>
899
+ {/* Background decoration */}
900
+ <div className="absolute top-0 right-0 w-64 h-64 opacity-10 pointer-events-none">
901
+ <div className={`w-full h-full bg-gradient-to-br ${typeStyle.gradient} rounded-full blur-3xl`} />
902
+ </div>
903
+
904
+ <div className="relative flex items-start justify-between">
905
+ <div className="flex items-center gap-4">
906
+ <div className={`p-4 rounded-2xl shadow-lg bg-gradient-to-br ${typeStyle.gradient}`}>
907
+ <div className="text-white">
908
+ {typeStyle.icon}
909
+ </div>
910
+ </div>
911
+ <div>
912
+ <h3 className="text-2xl font-bold text-slate-900 dark:text-white">{artifact.name}</h3>
913
+ <div className="flex items-center gap-3 mt-1">
914
+ <span className={`text-sm font-medium px-3 py-0.5 rounded-full ${typeStyle.iconBg}`}>
915
+ {artifact.type}
916
+ </span>
917
+ {artifact.step && (
918
+ <span className="text-sm text-slate-500 dark:text-slate-400">
919
+ from <span className="font-mono font-medium">{artifact.step}</span>
920
+ </span>
921
+ )}
922
+ </div>
923
+ </div>
741
924
  </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>
925
+ <div className="flex items-center gap-2">
926
+ <button
927
+ onClick={() => downloadArtifactById(artifact.artifact_id)}
928
+ 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`}
929
+ disabled={!artifact.artifact_id}
930
+ >
931
+ <Download size={16} /> Download
932
+ </button>
933
+ <button
934
+ onClick={onClose}
935
+ className="p-2.5 hover:bg-white/50 dark:hover:bg-slate-800 rounded-xl transition-colors"
936
+ >
937
+ <X size={22} className="text-slate-500 dark:text-slate-400" />
938
+ </button>
745
939
  </div>
746
940
  </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>
941
+
942
+ {/* Quick stats bar for Models */}
943
+ {isModel && modelInfo && (
944
+ <div className="mt-4 flex flex-wrap gap-4">
945
+ <QuickStat label="Framework" value={modelInfo.framework} icon={<Zap size={12} />} />
946
+ {modelInfo.parameters && <QuickStat label="Parameters" value={modelInfo.parameters.toLocaleString()} icon={<Activity size={12} />} />}
947
+ {modelInfo.layers && <QuickStat label="Layers" value={modelInfo.layers} icon={<Layers size={12} />} />}
948
+ {modelInfo.epochsTrained && <QuickStat label="Epochs" value={modelInfo.epochsTrained} icon={<TrendingUp size={12} />} />}
949
+ </div>
950
+ )}
951
+
952
+ {/* Quick stats bar for Datasets */}
953
+ {isDataset && datasetInfo && (
954
+ <div className="mt-4 flex flex-wrap gap-4">
955
+ {datasetInfo.samples && <QuickStat label="Samples" value={datasetInfo.samples.toLocaleString()} icon={<Database size={12} />} />}
956
+ {datasetInfo.features && <QuickStat label="Features" value={datasetInfo.features} icon={<Layers size={12} />} />}
957
+ {datasetInfo.split && <QuickStat label="Split" value={datasetInfo.split} icon={<FileText size={12} />} />}
958
+ {datasetInfo.source && <QuickStat label="Source" value={datasetInfo.source} icon={<Cloud size={12} />} />}
959
+ </div>
960
+ )}
762
961
  </div>
763
962
 
963
+ {/* Tabs - only show for complex types */}
964
+ {(isModel || isDataset || isMetrics) && (
965
+ <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">
966
+ <TabBtn active={activeTab === 'visualization'} onClick={() => setActiveTab('visualization')}>
967
+ {isModel ? <><LineChart size={14} /> Visualization</> :
968
+ isDataset ? <><BarChart2 size={14} /> Statistics</> :
969
+ <><Activity size={14} /> Metrics</>}
970
+ </TabBtn>
971
+ {displayableProps.length > 0 && (
972
+ <TabBtn active={activeTab === 'properties'} onClick={() => setActiveTab('properties')}>
973
+ <Tag size={14} /> Properties ({displayableProps.length})
974
+ </TabBtn>
975
+ )}
976
+ <TabBtn active={activeTab === 'metadata'} onClick={() => setActiveTab('metadata')}>
977
+ <Info size={14} /> Metadata
978
+ </TabBtn>
979
+ </div>
980
+ )}
981
+
764
982
  {/* 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)}
983
+ <div className="flex-1 overflow-y-auto p-6">
984
+ <AnimatePresence mode="wait">
985
+ {/* Visualization Tab */}
986
+ {activeTab === 'visualization' && (
987
+ <motion.div
988
+ key="viz"
989
+ initial={{ opacity: 0, y: 10 }}
990
+ animate={{ opacity: 1, y: 0 }}
991
+ exit={{ opacity: 0, y: -10 }}
992
+ className="space-y-6"
993
+ >
994
+ {/* Model with Training History */}
995
+ {isModel && effectiveTrainingHistory && (
996
+ <div>
997
+ <div className="flex items-center gap-2 mb-4">
998
+ <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
999
+ <LineChart size={18} className="text-purple-600 dark:text-purple-400" />
1000
+ </div>
1001
+ <h4 className="text-lg font-bold text-slate-900 dark:text-white">Training History</h4>
1002
+ <span className="text-xs bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded-full text-slate-500">
1003
+ {effectiveTrainingHistory.epochs.length} epochs
1004
+ </span>
1005
+ </div>
1006
+ <TrainingHistoryChart
1007
+ trainingHistory={effectiveTrainingHistory}
1008
+ compact={false}
1009
+ />
1010
+ </div>
1011
+ )}
1012
+
1013
+ {/* Model without Training History */}
1014
+ {isModel && !effectiveTrainingHistory && (
1015
+ <div className="text-center py-12">
1016
+ <div className="inline-flex p-4 bg-slate-100 dark:bg-slate-800 rounded-full mb-4">
1017
+ <Box size={32} className="text-slate-400" />
1018
+ </div>
1019
+ <h4 className="text-lg font-bold text-slate-900 dark:text-white mb-2">Model Artifact</h4>
1020
+ <p className="text-slate-500 dark:text-slate-400">
1021
+ No training history available for this model.
1022
+ <br />
1023
+ 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.
1024
+ </p>
1025
+ </div>
1026
+ )}
1027
+
1028
+ {/* Dataset Viewer - use the full DatasetViewer */}
1029
+ {isDataset && (
1030
+ <ArtifactViewer artifact={artifact} />
1031
+ )}
1032
+
1033
+ {/* Metrics Display */}
1034
+ {isMetrics && metricsData.length > 0 && (
1035
+ <div className="space-y-4">
1036
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
1037
+ {metricsData.map(([key, value], idx) => (
1038
+ <MetricDisplayCard
1039
+ key={key}
1040
+ name={key}
1041
+ value={value}
1042
+ index={idx}
1043
+ />
1044
+ ))}
1045
+ </div>
1046
+ </div>
1047
+ )}
1048
+
1049
+ {/* Generic Artifact */}
1050
+ {!isModel && !isDataset && !isMetrics && (
1051
+ <ArtifactViewer artifact={artifact} />
1052
+ )}
1053
+ </motion.div>
1054
+ )}
1055
+
1056
+ {/* Properties Tab */}
1057
+ {activeTab === 'properties' && (
1058
+ <motion.div
1059
+ key="props"
1060
+ initial={{ opacity: 0, y: 10 }}
1061
+ animate={{ opacity: 1, y: 0 }}
1062
+ exit={{ opacity: 0, y: -10 }}
1063
+ >
1064
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
1065
+ {displayableProps.map(([key, value]) => (
1066
+ <div
1067
+ key={key}
1068
+ className="p-4 bg-slate-50 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700"
1069
+ >
1070
+ <span className="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide block mb-1">
1071
+ {key.replace(/_/g, ' ')}
1072
+ </span>
1073
+ <span className="text-sm font-mono font-semibold text-slate-900 dark:text-white break-all">
1074
+ {typeof value === 'object' ? JSON.stringify(value, null, 2) :
1075
+ typeof value === 'number' ? value.toLocaleString() :
1076
+ String(value)}
780
1077
  </span>
781
1078
  </div>
782
1079
  ))}
783
1080
  </div>
784
- </div>
1081
+ </motion.div>
785
1082
  )}
786
1083
 
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>
1084
+ {/* Metadata Tab */}
1085
+ {activeTab === 'metadata' && (
1086
+ <motion.div
1087
+ key="meta"
1088
+ initial={{ opacity: 0, y: 10 }}
1089
+ animate={{ opacity: 1, y: 0 }}
1090
+ exit={{ opacity: 0, y: -10 }}
1091
+ className="space-y-4"
1092
+ >
1093
+ <div className="bg-slate-50 dark:bg-slate-800 rounded-xl p-6 border border-slate-200 dark:border-slate-700">
1094
+ <h5 className="text-sm font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wide mb-4">
1095
+ Artifact Information
1096
+ </h5>
1097
+ <div className="space-y-3">
1098
+ <MetadataRow label="Artifact ID" value={artifact.artifact_id} mono />
1099
+ <MetadataRow label="Type" value={artifact.type} />
1100
+ <MetadataRow label="Step" value={artifact.step} />
1101
+ {artifact.run_id && <MetadataRow label="Run ID" value={artifact.run_id} mono />}
1102
+ {artifact.pipeline_name && <MetadataRow label="Pipeline" value={artifact.pipeline_name} />}
1103
+ {artifact.created_at && (
1104
+ <MetadataRow
1105
+ label="Created"
1106
+ value={format(new Date(artifact.created_at), 'MMM d, yyyy HH:mm:ss')}
1107
+ />
1108
+ )}
803
1109
  </div>
804
- )}
805
- </div>
806
- </div>
807
- </div>
1110
+ </div>
1111
+ </motion.div>
1112
+ )}
1113
+ </AnimatePresence>
808
1114
  </div>
809
1115
 
810
1116
  {/* 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>
1117
+ <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">
1118
+ <p className="text-xs text-slate-500 dark:text-slate-400">
1119
+ 💡 Click Download to save the full artifact data
1120
+ </p>
1121
+ <div className="flex gap-2">
1122
+ <Button variant="ghost" onClick={onClose}>Close</Button>
1123
+ <Button
1124
+ variant="primary"
1125
+ onClick={() => downloadArtifactById(artifact.artifact_id)}
1126
+ disabled={!artifact.artifact_id}
1127
+ >
1128
+ <Download size={14} className="mr-1.5" />
1129
+ Download
1130
+ </Button>
1131
+ </div>
814
1132
  </div>
815
1133
  </motion.div>
816
1134
  </motion.div>
@@ -818,6 +1136,104 @@ function ArtifactModal({ artifact, onClose }) {
818
1136
  );
819
1137
  }
820
1138
 
1139
+ // Quick stat component for header
1140
+ function QuickStat({ label, value, icon }) {
1141
+ return (
1142
+ <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">
1143
+ <span className="text-slate-400">{icon}</span>
1144
+ <span className="text-xs text-slate-500 dark:text-slate-400">{label}:</span>
1145
+ <span className="text-sm font-bold text-slate-900 dark:text-white">{value}</span>
1146
+ </div>
1147
+ );
1148
+ }
1149
+
1150
+ // Tab button component
1151
+ function TabBtn({ active, onClick, children }) {
1152
+ return (
1153
+ <button
1154
+ onClick={onClick}
1155
+ className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all ${active
1156
+ ? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm'
1157
+ : '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'
1158
+ }`}
1159
+ >
1160
+ {children}
1161
+ </button>
1162
+ );
1163
+ }
1164
+
1165
+ // Metric display card for Metrics artifacts
1166
+ function MetricDisplayCard({ name, value, index }) {
1167
+ const colors = [
1168
+ 'from-blue-500 to-indigo-600',
1169
+ 'from-purple-500 to-violet-600',
1170
+ 'from-emerald-500 to-teal-600',
1171
+ 'from-amber-500 to-orange-600',
1172
+ 'from-rose-500 to-pink-600',
1173
+ 'from-cyan-500 to-blue-600',
1174
+ ];
1175
+ const color = colors[index % colors.length];
1176
+
1177
+ // Format name nicely
1178
+ const displayName = name
1179
+ .replace(/_/g, ' ')
1180
+ .replace(/\b\w/g, c => c.toUpperCase());
1181
+
1182
+ // Format value
1183
+ const displayValue = typeof value === 'number'
1184
+ ? (Math.abs(value) < 0.01 || Math.abs(value) > 1000
1185
+ ? value.toExponential(3)
1186
+ : value.toFixed(4))
1187
+ : value;
1188
+
1189
+ // Determine if this is a "good" metric (accuracy, score) or "bad" (loss, error)
1190
+ const isLossLike = name.toLowerCase().includes('loss') ||
1191
+ name.toLowerCase().includes('error') ||
1192
+ name.toLowerCase().includes('mse') ||
1193
+ name.toLowerCase().includes('mae');
1194
+
1195
+ return (
1196
+ <motion.div
1197
+ initial={{ opacity: 0, scale: 0.9 }}
1198
+ animate={{ opacity: 1, scale: 1 }}
1199
+ transition={{ delay: index * 0.05 }}
1200
+ 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"
1201
+ >
1202
+ <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`} />
1203
+ <div className="relative">
1204
+ <p className="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1 truncate">
1205
+ {displayName}
1206
+ </p>
1207
+ <p className="text-2xl font-bold font-mono text-slate-900 dark:text-white">
1208
+ {displayValue}
1209
+ </p>
1210
+ <div className="mt-1 flex items-center gap-1">
1211
+ {isLossLike ? (
1212
+ <TrendingDown size={12} className="text-emerald-500" />
1213
+ ) : (
1214
+ <TrendingUp size={12} className="text-emerald-500" />
1215
+ )}
1216
+ <span className="text-xs text-slate-400">
1217
+ {isLossLike ? 'Lower is better' : 'Higher is better'}
1218
+ </span>
1219
+ </div>
1220
+ </div>
1221
+ </motion.div>
1222
+ );
1223
+ }
1224
+
1225
+ // Metadata row component
1226
+ function MetadataRow({ label, value, mono = false }) {
1227
+ return (
1228
+ <div className="flex justify-between items-center py-2 border-b border-slate-200/50 dark:border-slate-700/50 last:border-0">
1229
+ <span className="text-sm text-slate-500 dark:text-slate-400">{label}</span>
1230
+ <span className={`text-sm font-medium text-slate-900 dark:text-white ${mono ? 'font-mono text-xs' : ''}`}>
1231
+ {value || '—'}
1232
+ </span>
1233
+ </div>
1234
+ );
1235
+ }
1236
+
821
1237
  function LogsViewer({ runId, stepName, isRunning, maxHeight = "max-h-96", minimal = false }) {
822
1238
  const [logs, setLogs] = useState('');
823
1239
  const [loading, setLoading] = useState(true);
@@ -964,3 +1380,87 @@ function LogsViewer({ runId, stepName, isRunning, maxHeight = "max-h-96", minima
964
1380
  </div>
965
1381
  );
966
1382
  }
1383
+
1384
+ /**
1385
+ * Training Metrics Section Wrapper
1386
+ * Only renders the training metrics section if there's actual training data.
1387
+ * This prevents showing an empty section for non-training pipelines.
1388
+ */
1389
+ function TrainingMetricsSectionWrapper({ runId, isRunning }) {
1390
+ const [hasData, setHasData] = useState(false);
1391
+ const [isExpanded, setIsExpanded] = useState(true);
1392
+ const [loading, setLoading] = useState(true);
1393
+
1394
+ // Check if training data exists
1395
+ useEffect(() => {
1396
+ const checkTrainingData = async () => {
1397
+ try {
1398
+ const res = await fetchApi(`/api/runs/${runId}/training-history`);
1399
+ if (res.ok) {
1400
+ const data = await res.json();
1401
+ setHasData(data.has_history && data.training_history?.epochs?.length > 0);
1402
+ }
1403
+ } catch (err) {
1404
+ console.error('Error checking training history:', err);
1405
+ } finally {
1406
+ setLoading(false);
1407
+ }
1408
+ };
1409
+
1410
+ if (runId) {
1411
+ checkTrainingData();
1412
+ }
1413
+ }, [runId]);
1414
+
1415
+ // Don't render anything if no training data
1416
+ if (loading) return null;
1417
+ if (!hasData) return null;
1418
+
1419
+ return (
1420
+ <div className="mt-8">
1421
+ <Card className="overflow-hidden">
1422
+ <div
1423
+ 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"
1424
+ onClick={() => setIsExpanded(!isExpanded)}
1425
+ >
1426
+ <div className="flex items-center gap-3">
1427
+ <div className="p-2.5 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl shadow-lg">
1428
+ <LineChart size={20} className="text-white" />
1429
+ </div>
1430
+ <div>
1431
+ <h3 className="text-lg font-bold text-slate-900 dark:text-white">
1432
+ Training Metrics
1433
+ </h3>
1434
+ <p className="text-sm text-slate-500 dark:text-slate-400">
1435
+ Interactive training history visualization
1436
+ </p>
1437
+ </div>
1438
+ </div>
1439
+ <ChevronRight
1440
+ size={20}
1441
+ className={`text-slate-400 transform transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
1442
+ />
1443
+ </div>
1444
+ <AnimatePresence>
1445
+ {isExpanded && (
1446
+ <motion.div
1447
+ initial={{ height: 0, opacity: 0 }}
1448
+ animate={{ height: 'auto', opacity: 1 }}
1449
+ exit={{ height: 0, opacity: 0 }}
1450
+ transition={{ duration: 0.2 }}
1451
+ className="overflow-hidden"
1452
+ >
1453
+ <div className="p-6">
1454
+ <TrainingMetricsPanel
1455
+ runId={runId}
1456
+ isRunning={isRunning}
1457
+ autoRefresh={true}
1458
+ />
1459
+ </div>
1460
+ </motion.div>
1461
+ )}
1462
+ </AnimatePresence>
1463
+ </Card>
1464
+ </div>
1465
+ );
1466
+ }