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
@@ -1,45 +1,124 @@
1
- import React, { useEffect, useState } from 'react';
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState, useMemo, useCallback } from 'react';
2
4
  import { fetchApi } from '../../utils/api';
3
5
  import { downloadArtifactById } from '../../utils/downloads';
4
6
  import { Link } from 'react-router-dom';
5
- import { Database, Box, BarChart2, FileText, Search, Filter, Calendar, Package, Download, Eye, X, ArrowRight, Network, Activity, HardDrive, List, Grid } from 'lucide-react';
7
+ import {
8
+ Database, Box, BarChart2, FileText, Search, Filter, Calendar, Package,
9
+ Download, Eye, X, ArrowRight, Network, Activity, HardDrive, List,
10
+ Grid, ChevronDown, ChevronRight, Folder, FolderOpen, FileBox, Clock,
11
+ Layers, GitBranch, CheckCircle, XCircle, RefreshCw, SlidersHorizontal,
12
+ TrendingUp, Zap, Hash, MoreHorizontal, ExternalLink, Copy, Trash2,
13
+ Star, Bookmark, ArrowUpDown, LayoutGrid, Maximize2, Minimize2, GripVertical
14
+ } from 'lucide-react';
6
15
  import { Card } from '../../components/ui/Card';
7
16
  import { Badge } from '../../components/ui/Badge';
8
17
  import { Button } from '../../components/ui/Button';
9
- import { format } from 'date-fns';
18
+ import { format, formatDistanceToNow } from 'date-fns';
10
19
  import { motion, AnimatePresence } from 'framer-motion';
11
- import { DataView } from '../../components/ui/DataView';
12
20
  import { useProject } from '../../contexts/ProjectContext';
13
21
  import { EmptyState } from '../../components/ui/EmptyState';
14
- import { KeyValue, KeyValueGrid } from '../../components/ui/KeyValue';
15
- import { AssetStatsDashboard } from '../../components/AssetStatsDashboard';
16
- import { AssetTreeHierarchy } from '../../components/AssetTreeHierarchy';
17
22
  import { AssetDetailsPanel } from '../../components/AssetDetailsPanel';
18
- import { ProjectSelector } from '../../components/ProjectSelector';
23
+ import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels';
24
+
25
+ // Type configuration with icons and colors
26
+ const TYPE_CONFIG = {
27
+ Model: {
28
+ icon: Box,
29
+ color: 'text-purple-500',
30
+ bg: 'bg-purple-50 dark:bg-purple-900/20',
31
+ gradient: 'from-purple-500 to-pink-500',
32
+ borderColor: 'border-purple-200 dark:border-purple-800',
33
+ label: 'Models'
34
+ },
35
+ Dataset: {
36
+ icon: Database,
37
+ color: 'text-blue-500',
38
+ bg: 'bg-blue-50 dark:bg-blue-900/20',
39
+ gradient: 'from-blue-500 to-cyan-500',
40
+ borderColor: 'border-blue-200 dark:border-blue-800',
41
+ label: 'Datasets'
42
+ },
43
+ Metrics: {
44
+ icon: BarChart2,
45
+ color: 'text-emerald-500',
46
+ bg: 'bg-emerald-50 dark:bg-emerald-900/20',
47
+ gradient: 'from-emerald-500 to-teal-500',
48
+ borderColor: 'border-emerald-200 dark:border-emerald-800',
49
+ label: 'Metrics'
50
+ },
51
+ FeatureSet: {
52
+ icon: Layers,
53
+ color: 'text-amber-500',
54
+ bg: 'bg-amber-50 dark:bg-amber-900/20',
55
+ gradient: 'from-amber-500 to-orange-500',
56
+ borderColor: 'border-amber-200 dark:border-amber-800',
57
+ label: 'Features'
58
+ },
59
+ default: {
60
+ icon: FileText,
61
+ color: 'text-slate-500',
62
+ bg: 'bg-slate-50 dark:bg-slate-800',
63
+ gradient: 'from-slate-500 to-slate-600',
64
+ borderColor: 'border-slate-200 dark:border-slate-700',
65
+ label: 'Other'
66
+ }
67
+ };
68
+
69
+ const getTypeConfig = (type) => TYPE_CONFIG[type] || TYPE_CONFIG.default;
19
70
 
20
71
  export function Assets() {
21
72
  const [assets, setAssets] = useState([]);
73
+ const [runs, setRuns] = useState([]);
74
+ const [pipelines, setPipelines] = useState([]);
22
75
  const [loading, setLoading] = useState(true);
23
76
  const [error, setError] = useState(null);
77
+ const [searchQuery, setSearchQuery] = useState('');
24
78
  const [typeFilter, setTypeFilter] = useState('all');
25
79
  const [selectedAsset, setSelectedAsset] = useState(null);
26
- const [viewMode, setViewMode] = useState('table'); // Default to table for better density
80
+ const [viewMode, setViewMode] = useState('table');
81
+ const [sortConfig, setSortConfig] = useState({ key: 'created_at', direction: 'desc' });
82
+ const [detailsPanelExpanded, setDetailsPanelExpanded] = useState(false);
27
83
  const [stats, setStats] = useState(null);
84
+ const [expandedProjects, setExpandedProjects] = useState({});
85
+ const [expandedPipelines, setExpandedPipelines] = useState({});
86
+ const [expandedRuns, setExpandedRuns] = useState({});
87
+ const [showExplorer, setShowExplorer] = useState(true);
88
+ const [hideListWhenDetails, setHideListWhenDetails] = useState(false);
28
89
  const { selectedProject } = useProject();
29
90
 
91
+ // Fetch all data
30
92
  useEffect(() => {
31
- const fetchAssets = async () => {
93
+ const fetchData = async () => {
32
94
  setLoading(true);
33
95
  setError(null);
34
96
  try {
35
- const url = selectedProject
36
- ? `/api/assets/?limit=50&project=${encodeURIComponent(selectedProject)}`
37
- : '/api/assets/?limit=50';
38
- const res = await fetchApi(url);
39
- if (!res.ok) throw new Error(`Failed to fetch assets: ${res.statusText}`);
40
-
41
- const data = await res.json();
42
- setAssets(data.assets || []);
97
+ const baseUrl = selectedProject ? `&project=${encodeURIComponent(selectedProject)}` : '';
98
+ const [assetsRes, runsRes, pipelinesRes, statsRes] = await Promise.all([
99
+ fetchApi(`/api/assets/?limit=500${baseUrl.replace('&', '?')}`),
100
+ fetchApi(`/api/runs?limit=200${baseUrl}`),
101
+ fetchApi(`/api/pipelines?limit=100${baseUrl}`),
102
+ fetchApi(`/api/assets/stats${baseUrl.replace('&', '?')}`)
103
+ ]);
104
+
105
+ const [assetsData, runsData, pipelinesData, statsData] = await Promise.all([
106
+ assetsRes.json(),
107
+ runsRes.json(),
108
+ pipelinesRes.json(),
109
+ statsRes.ok ? statsRes.json() : null
110
+ ]);
111
+
112
+ setAssets(assetsData.assets || []);
113
+ setRuns(runsData.runs || []);
114
+ setPipelines(pipelinesData.pipelines || []);
115
+ setStats(statsData);
116
+
117
+ // Auto-expand first project
118
+ const projects = [...new Set((assetsData.assets || []).map(a => a.project).filter(Boolean))];
119
+ if (projects.length > 0) {
120
+ setExpandedProjects({ [projects[0]]: true });
121
+ }
43
122
  } catch (err) {
44
123
  console.error(err);
45
124
  setError(err.message);
@@ -47,212 +126,103 @@ export function Assets() {
47
126
  setLoading(false);
48
127
  }
49
128
  };
50
- fetchAssets();
129
+ fetchData();
51
130
  }, [selectedProject]);
52
131
 
53
- // Fetch stats for compact display
54
- useEffect(() => {
55
- const fetchStats = async () => {
56
- try {
57
- const url = selectedProject
58
- ? `/api/assets/stats?project=${encodeURIComponent(selectedProject)}`
59
- : '/api/assets/stats';
60
- const res = await fetchApi(url);
61
- if (res.ok) {
62
- const data = await res.json();
63
- setStats(data);
64
- }
65
- } catch (err) {
66
- console.error('Failed to fetch stats:', err);
132
+ // Get unique types with counts
133
+ const typeCounts = useMemo(() => {
134
+ const counts = { all: assets.length };
135
+ assets.forEach(a => {
136
+ counts[a.type] = (counts[a.type] || 0) + 1;
137
+ });
138
+ return counts;
139
+ }, [assets]);
140
+
141
+ // Filter and sort assets
142
+ const filteredAssets = useMemo(() => {
143
+ let result = assets.filter(asset => {
144
+ const matchesType = typeFilter === 'all' || asset.type === typeFilter;
145
+ const matchesSearch = !searchQuery ||
146
+ asset.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
147
+ asset.step?.toLowerCase().includes(searchQuery.toLowerCase()) ||
148
+ asset.pipeline_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
149
+ asset.project?.toLowerCase().includes(searchQuery.toLowerCase());
150
+ return matchesType && matchesSearch;
151
+ });
152
+
153
+ // Sort
154
+ result.sort((a, b) => {
155
+ let aVal = a[sortConfig.key];
156
+ let bVal = b[sortConfig.key];
157
+
158
+ if (sortConfig.key === 'created_at') {
159
+ aVal = new Date(aVal || 0).getTime();
160
+ bVal = new Date(bVal || 0).getTime();
67
161
  }
68
- };
69
162
 
70
- fetchStats();
71
- }, [selectedProject]);
163
+ if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
164
+ if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
165
+ return 0;
166
+ });
72
167
 
73
- // Get unique types
74
- const types = ['all', ...new Set(assets.map(a => a.type))];
75
-
76
- // Filter assets
77
- const filteredAssets = assets.filter(asset => {
78
- return typeFilter === 'all' || asset.type === typeFilter;
79
- });
80
-
81
- const columns = [
82
- {
83
- header: 'Type',
84
- key: 'type',
85
- sortable: true,
86
- render: (asset) => {
87
- const typeConfig = {
88
- Dataset: { icon: <Database size={14} />, color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
89
- Model: { icon: <Box size={14} />, color: 'text-purple-600', bg: 'bg-purple-50 dark:bg-purple-900/20' },
90
- Metrics: { icon: <BarChart2 size={14} />, color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20' },
91
- default: { icon: <FileText size={14} />, color: 'text-slate-600', bg: 'bg-slate-50 dark:bg-slate-800' }
92
- };
93
- const config = typeConfig[asset.type] || typeConfig.default;
94
- return (
95
- <div className={`flex items-center gap-2 px-2 py-1 rounded-md w-fit ${config.bg} ${config.color}`}>
96
- {config.icon}
97
- <span className="text-xs font-medium">{asset.type}</span>
98
- </div>
99
- );
168
+ return result;
169
+ }, [assets, typeFilter, searchQuery, sortConfig]);
170
+
171
+ // Group assets by hierarchy
172
+ const hierarchy = useMemo(() => {
173
+ const projects = {};
174
+
175
+ assets.forEach(asset => {
176
+ const projectName = asset.project || 'Unassigned';
177
+ const pipelineName = asset.pipeline_name || 'Direct';
178
+ const runId = asset.run_id || 'unknown';
179
+
180
+ if (!projects[projectName]) {
181
+ projects[projectName] = { pipelines: {}, count: 0 };
182
+ }
183
+ if (!projects[projectName].pipelines[pipelineName]) {
184
+ projects[projectName].pipelines[pipelineName] = { runs: {}, count: 0 };
185
+ }
186
+ if (!projects[projectName].pipelines[pipelineName].runs[runId]) {
187
+ projects[projectName].pipelines[pipelineName].runs[runId] = [];
100
188
  }
101
- },
102
- {
103
- header: 'Name',
104
- key: 'name',
105
- sortable: true,
106
- render: (asset) => (
107
- <span className="font-medium text-slate-900 dark:text-white">{asset.name}</span>
108
- )
109
- },
110
- {
111
- header: 'Step',
112
- key: 'step',
113
- sortable: true,
114
- render: (asset) => (
115
- <span className="font-mono text-xs text-slate-500">{asset.step}</span>
116
- )
117
- },
118
- {
119
- header: 'Pipeline',
120
- key: 'pipeline',
121
- sortable: true,
122
- render: (asset) => (
123
- <span className="text-sm text-slate-600 dark:text-slate-400">
124
- {asset.pipeline_name || '-'}
125
- </span>
126
- )
127
- },
128
- {
129
- header: 'Project',
130
- key: 'project',
131
- sortable: true,
132
- render: (asset) => (
133
- <span className="text-sm text-slate-600 dark:text-slate-400">
134
- {asset.project || '-'}
135
- </span>
136
- )
137
- },
138
- {
139
- header: 'Run ID',
140
- key: 'run_id',
141
- render: (asset) => asset.run_id ? (
142
- <Link
143
- to={`/runs/${asset.run_id}`}
144
- className="font-mono text-xs text-primary-600 hover:underline"
145
- onClick={(e) => e.stopPropagation()}
146
- >
147
- {asset.run_id?.substring(0, 8) || 'N/A'}
148
- </Link>
149
- ) : (
150
- <span className="font-mono text-xs text-slate-400">-</span>
151
- )
152
- },
153
- {
154
- header: 'Created',
155
- key: 'created_at',
156
- sortable: true,
157
- render: (asset) => (
158
- <span className="text-sm text-slate-500">
159
- {asset.created_at ? format(new Date(asset.created_at), 'MMM d, HH:mm') : '-'}
160
- </span>
161
- )
162
- },
163
- {
164
- header: '',
165
- key: 'actions',
166
- render: (asset) => (
167
- <div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
168
- <button
169
- onClick={() => setSelectedAsset(asset)}
170
- className="p-1 hover:bg-slate-100 dark:hover:bg-slate-800 rounded text-slate-500 hover:text-primary-600"
171
- title="View Details"
172
- >
173
- <Eye size={16} />
174
- </button>
175
- <button
176
- onClick={(e) => {
177
- e.stopPropagation();
178
- downloadArtifactById(asset.artifact_id);
179
- }}
180
- className="p-1 hover:bg-slate-100 dark:hover:bg-slate-800 rounded text-slate-500 hover:text-primary-600"
181
- disabled={!asset.artifact_id}
182
- title="Download"
183
- >
184
- <Download size={16} />
185
- </button>
186
- </div>
187
- )
188
- }
189
- ];
190
-
191
- const renderGrid = (asset) => {
192
- const typeConfig = {
193
- Dataset: { icon: <Database size={18} />, color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
194
- Model: { icon: <Box size={18} />, color: 'text-purple-600', bg: 'bg-purple-50 dark:bg-purple-900/20' },
195
- Metrics: { icon: <BarChart2 size={18} />, color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20' },
196
- default: { icon: <FileText size={18} />, color: 'text-slate-600', bg: 'bg-slate-50 dark:bg-slate-800' }
197
- };
198
189
 
199
- const config = typeConfig[asset.type] || typeConfig.default;
190
+ projects[projectName].pipelines[pipelineName].runs[runId].push(asset);
191
+ projects[projectName].pipelines[pipelineName].count++;
192
+ projects[projectName].count++;
193
+ });
200
194
 
201
- return (
202
- <motion.div
203
- initial={{ opacity: 0, y: 10 }}
204
- animate={{ opacity: 1, y: 0 }}
205
- whileHover={{ y: -2 }}
206
- transition={{ duration: 0.2 }}
207
- >
208
- <Card
209
- className="group cursor-pointer hover:shadow-md hover:border-primary-200 dark:hover:border-primary-800 transition-all duration-200 h-full border border-slate-200 dark:border-slate-800"
210
- onClick={() => setSelectedAsset(asset)}
211
- >
212
- <div className="p-4">
213
- <div className="flex items-start justify-between mb-3">
214
- <div className={`p-2 rounded-lg ${config.bg} ${config.color}`}>
215
- {config.icon}
216
- </div>
217
- <Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5 border-slate-200 dark:border-slate-700 text-slate-500">
218
- {asset.type}
219
- </Badge>
220
- </div>
195
+ return projects;
196
+ }, [assets]);
221
197
 
222
- <h4 className="font-medium text-slate-900 dark:text-white mb-1 truncate group-hover:text-primary-600 transition-colors">
223
- {asset.name}
224
- </h4>
225
-
226
- <div className="flex items-center gap-2 mb-4 text-xs text-slate-500">
227
- <span className="truncate max-w-[120px]">{asset.step}</span>
228
- {asset.project && (
229
- <>
230
- <span>•</span>
231
- <span className="truncate max-w-[80px]">{asset.project}</span>
232
- </>
233
- )}
234
- </div>
198
+ const handleSort = (key) => {
199
+ setSortConfig(prev => ({
200
+ key,
201
+ direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'
202
+ }));
203
+ };
235
204
 
236
- <div className="pt-3 border-t border-slate-100 dark:border-slate-800 flex items-center justify-between text-xs text-slate-400">
237
- <span>
238
- {asset.created_at ? format(new Date(asset.created_at), 'MMM d') : '-'}
239
- </span>
240
- {asset.run_id && (
241
- <span className="font-mono bg-slate-50 dark:bg-slate-800 px-1.5 py-0.5 rounded">
242
- {asset.run_id.slice(0, 6)}
243
- </span>
244
- )}
245
- </div>
246
- </div>
247
- </Card>
248
- </motion.div>
249
- );
205
+ const toggleProject = (name) => {
206
+ setExpandedProjects(prev => ({ ...prev, [name]: !prev[name] }));
207
+ };
208
+
209
+ const togglePipeline = (projectName, pipelineName) => {
210
+ const key = `${projectName}-${pipelineName}`;
211
+ setExpandedPipelines(prev => ({ ...prev, [key]: !prev[key] }));
212
+ };
213
+
214
+ const toggleRun = (projectName, pipelineName, runId) => {
215
+ const key = `${projectName}-${pipelineName}-${runId}`;
216
+ setExpandedRuns(prev => ({ ...prev, [key]: !prev[key] }));
250
217
  };
251
218
 
252
219
  if (loading) {
253
220
  return (
254
221
  <div className="flex items-center justify-center h-screen bg-slate-50 dark:bg-slate-900">
255
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
222
+ <div className="text-center">
223
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
224
+ <p className="text-slate-500 dark:text-slate-400">Loading assets...</p>
225
+ </div>
256
226
  </div>
257
227
  );
258
228
  }
@@ -260,15 +230,15 @@ export function Assets() {
260
230
  if (error) {
261
231
  return (
262
232
  <div className="flex items-center justify-center h-screen bg-slate-50 dark:bg-slate-900">
263
- <div className="text-center p-8 bg-red-50 dark:bg-red-900/20 rounded-2xl border border-red-100 dark:border-red-800 max-w-md">
233
+ <div className="text-center p-8 bg-red-50 dark:bg-red-900/20 rounded-2xl border border-red-200 dark:border-red-800 max-w-md">
264
234
  <XCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
265
235
  <h3 className="text-lg font-bold text-red-700 dark:text-red-300 mb-2">Failed to load assets</h3>
266
- <p className="text-red-600 dark:text-red-400 mb-6">{error}</p>
236
+ <p className="text-red-600 dark:text-red-400 mb-4">{error}</p>
267
237
  <button
268
238
  onClick={() => window.location.reload()}
269
- className="px-4 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300 font-medium"
239
+ className="px-4 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors flex items-center gap-2 mx-auto"
270
240
  >
271
- Retry Connection
241
+ <RefreshCw size={16} /> Retry
272
242
  </button>
273
243
  </div>
274
244
  </div>
@@ -276,152 +246,751 @@ export function Assets() {
276
246
  }
277
247
 
278
248
  return (
279
- <div className="h-screen flex flex-col overflow-hidden bg-slate-50 dark:bg-slate-900">
249
+ <div className="h-screen flex flex-col overflow-hidden bg-gradient-to-br from-slate-50 via-slate-50 to-blue-50/30 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800">
280
250
  {/* Header */}
281
- <div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-6 py-4">
282
- <div className="flex items-center justify-between max-w-[1800px] mx-auto">
251
+ <header className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg border-b border-slate-200 dark:border-slate-700 px-6 py-4 shrink-0">
252
+ <div className="flex items-center justify-between gap-6 max-w-[2000px] mx-auto">
253
+ {/* Title & Stats */}
254
+ <div className="flex items-center gap-6">
283
255
  <div>
284
- <h1 className="text-xl font-bold text-slate-900 dark:text-white">Assets</h1>
285
- <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
286
- Manage and track your pipeline artifacts
287
- </p>
256
+ <h1 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
257
+ <div className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl text-white">
258
+ <Package size={24} />
259
+ </div>
260
+ Asset Explorer
261
+ </h1>
262
+ <p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
263
+ Browse, search, and manage all pipeline artifacts
264
+ </p>
265
+ </div>
266
+
267
+ {/* Quick Stats */}
268
+ <div className="hidden lg:flex items-center gap-3 pl-6 border-l border-slate-200 dark:border-slate-700">
269
+ <StatBadge icon={Package} value={stats?.total_assets || assets.length} label="Total" />
270
+ <StatBadge icon={Box} value={typeCounts.Model || 0} label="Models" color="purple" />
271
+ <StatBadge icon={Database} value={typeCounts.Dataset || 0} label="Datasets" color="blue" />
272
+ <StatBadge icon={BarChart2} value={typeCounts.Metrics || 0} label="Metrics" color="emerald" />
273
+ </div>
288
274
  </div>
289
275
 
290
- {!selectedAsset && (
291
- <div className="flex items-center gap-2 bg-slate-100 dark:bg-slate-700 p-1 rounded-lg">
276
+ {/* Search & Controls */}
277
+ <div className="flex items-center gap-3">
278
+ {/* Search */}
279
+ <div className="relative">
280
+ <Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
281
+ <input
282
+ type="text"
283
+ placeholder="Search assets..."
284
+ value={searchQuery}
285
+ onChange={(e) => setSearchQuery(e.target.value)}
286
+ className="w-64 pl-10 pr-4 py-2 bg-slate-100 dark:bg-slate-700 border-0 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none transition-all"
287
+ />
288
+ {searchQuery && (
289
+ <button
290
+ onClick={() => setSearchQuery('')}
291
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
292
+ >
293
+ <X size={14} />
294
+ </button>
295
+ )}
296
+ </div>
297
+
298
+ {/* View Mode Toggle */}
299
+ <div className="flex items-center bg-slate-100 dark:bg-slate-700 rounded-xl p-1">
292
300
  <button
293
- onClick={() => setViewMode('grid')}
294
- className={`p-1.5 rounded transition-all ${viewMode === 'grid'
301
+ onClick={() => setViewMode('table')}
302
+ className={`p-2 rounded-lg transition-all ${viewMode === 'table'
295
303
  ? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-white shadow-sm'
296
- : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
297
- }`}
298
- title="Grid View"
304
+ : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
305
+ }`}
306
+ title="Table View"
299
307
  >
300
- <Grid size={16} />
308
+ <List size={18} />
301
309
  </button>
302
310
  <button
303
- onClick={() => setViewMode('table')}
304
- className={`p-1.5 rounded transition-all ${viewMode === 'table'
311
+ onClick={() => setViewMode('grid')}
312
+ className={`p-2 rounded-lg transition-all ${viewMode === 'grid'
305
313
  ? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-white shadow-sm'
306
- : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
314
+ : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
307
315
  }`}
308
- title="Table View"
316
+ title="Grid View"
309
317
  >
310
- <List size={16} />
318
+ <LayoutGrid size={18} />
311
319
  </button>
312
320
  </div>
313
- )}
321
+
322
+ {/* Explorer Toggle */}
323
+ <button
324
+ onClick={() => setShowExplorer(!showExplorer)}
325
+ className={`p-2 rounded-xl transition-all ${showExplorer
326
+ ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
327
+ : 'bg-slate-100 dark:bg-slate-700 text-slate-500'
328
+ }`}
329
+ title={showExplorer ? 'Hide Explorer' : 'Show Explorer'}
330
+ >
331
+ <FolderOpen size={18} />
332
+ </button>
333
+ </div>
334
+ </div>
335
+ </header>
336
+
337
+ {/* Type Filters */}
338
+ <div className="bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700 px-6 py-3 shrink-0">
339
+ <div className="flex items-center gap-2 max-w-[2000px] mx-auto overflow-x-auto scrollbar-hide">
340
+ <span className="text-xs font-medium text-slate-500 dark:text-slate-400 shrink-0 mr-2">
341
+ <Filter size={14} className="inline mr-1" />
342
+ Filter:
343
+ </span>
344
+ {Object.entries({ all: 'All', ...Object.fromEntries(Object.keys(typeCounts).filter(k => k !== 'all').map(k => [k, k])) }).map(([key, label]) => {
345
+ const config = getTypeConfig(key);
346
+ const isActive = typeFilter === key;
347
+ const count = typeCounts[key] || 0;
348
+ const Icon = config.icon;
349
+
350
+ return (
351
+ <button
352
+ key={key}
353
+ onClick={() => setTypeFilter(key)}
354
+ className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${isActive
355
+ ? `bg-gradient-to-r ${config.gradient} text-white shadow-md`
356
+ : `${config.bg} ${config.color} hover:shadow-sm`
357
+ }`}
358
+ >
359
+ {key !== 'all' && <Icon size={14} />}
360
+ <span>{label}</span>
361
+ <span className={`px-1.5 py-0.5 rounded-full text-[10px] ${isActive ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
362
+ {count}
363
+ </span>
364
+ </button>
365
+ );
366
+ })}
314
367
  </div>
315
368
  </div>
316
369
 
317
370
  {/* Main Content */}
318
- <div className="flex-1 overflow-hidden">
319
- <div className="h-full max-w-[1800px] mx-auto px-6 py-6">
320
- <div className="h-full flex gap-6">
321
- {/* Sidebar */}
322
- <div className="w-[380px] shrink-0 flex flex-col gap-4 overflow-y-auto pb-6">
323
- {/* Stats */}
324
- <div className="grid grid-cols-2 gap-3">
325
- <StatCardCompact
326
- icon={Package}
327
- label="Total"
328
- value={stats?.total_assets || 0}
329
- />
330
- <StatCardCompact
331
- icon={HardDrive}
332
- label="Size"
333
- value={formatBytes(stats?.total_storage_bytes || 0)}
334
- />
335
- </div>
336
-
337
- {/* Tree */}
338
- <div className="flex-1 min-h-0 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden flex flex-col">
339
- <div className="p-3 border-b border-slate-100 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/50">
340
- <h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Explorer</h3>
371
+ <div className="flex-1 overflow-hidden flex">
372
+ {/* Explorer Sidebar */}
373
+ <AnimatePresence>
374
+ {showExplorer && (
375
+ <motion.aside
376
+ initial={{ width: 0, opacity: 0 }}
377
+ animate={{ width: 320, opacity: 1 }}
378
+ exit={{ width: 0, opacity: 0 }}
379
+ transition={{ duration: 0.2 }}
380
+ className="h-full border-r border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm overflow-hidden shrink-0"
381
+ >
382
+ <div className="h-full flex flex-col">
383
+ <div className="p-4 border-b border-slate-100 dark:border-slate-700">
384
+ <h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-2">
385
+ <Folder size={16} className="text-blue-500" />
386
+ Project Explorer
387
+ </h3>
341
388
  </div>
389
+
342
390
  <div className="flex-1 overflow-y-auto p-2">
343
- <AssetTreeHierarchy
344
- projectId={selectedProject}
345
- onAssetSelect={(asset) => setSelectedAsset(asset)}
346
- compact={true}
347
- />
391
+ {Object.entries(hierarchy).length === 0 ? (
392
+ <div className="text-center py-8 text-slate-500">
393
+ <Folder size={32} className="mx-auto mb-2 opacity-50" />
394
+ <p className="text-sm">No assets found</p>
395
+ </div>
396
+ ) : (
397
+ Object.entries(hierarchy).map(([projectName, projectData]) => (
398
+ <ExplorerProject
399
+ key={projectName}
400
+ name={projectName}
401
+ data={projectData}
402
+ expanded={expandedProjects[projectName]}
403
+ onToggle={() => toggleProject(projectName)}
404
+ expandedPipelines={expandedPipelines}
405
+ expandedRuns={expandedRuns}
406
+ onTogglePipeline={(p) => togglePipeline(projectName, p)}
407
+ onToggleRun={(p, r) => toggleRun(projectName, p, r)}
408
+ onAssetSelect={setSelectedAsset}
409
+ typeFilter={typeFilter}
410
+ />
411
+ ))
412
+ )}
348
413
  </div>
349
414
  </div>
350
- </div>
415
+ </motion.aside>
416
+ )}
417
+ </AnimatePresence>
351
418
 
352
- {/* Content Area */}
353
- <div className="flex-1 min-w-0 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden flex flex-col shadow-sm">
354
- {selectedAsset ? (
355
- <AssetDetailsPanel
356
- asset={selectedAsset}
357
- onClose={() => setSelectedAsset(null)}
358
- />
359
- ) : (
360
- <div className="h-full flex flex-col">
361
- {/* Filters */}
362
- <div className="p-4 border-b border-slate-100 dark:border-slate-700 flex items-center gap-4 overflow-x-auto">
363
- <Filter size={16} className="text-slate-400 shrink-0" />
364
- <div className="flex gap-2">
365
- {types.map(type => (
366
- <button
367
- key={type}
368
- onClick={() => setTypeFilter(type)}
369
- className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap border ${typeFilter === type
370
- ? 'bg-slate-900 text-white border-slate-900 dark:bg-white dark:text-slate-900 dark:border-white'
371
- : 'bg-white text-slate-600 border-slate-200 hover:border-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700 dark:hover:border-slate-600'
372
- }`}
373
- >
374
- {type === 'all' ? 'All' : type}
375
- </button>
376
- ))}
419
+ {/* Content Area with Resizable Panels */}
420
+ <main className="flex-1 min-w-0 overflow-hidden">
421
+ <PanelGroup direction="horizontal" className="h-full">
422
+ {/* Asset List Panel - can be hidden when viewing details */}
423
+ {(!selectedAsset || !hideListWhenDetails) && (
424
+ <Panel
425
+ defaultSize={selectedAsset ? (detailsPanelExpanded ? 25 : 35) : 100}
426
+ minSize={selectedAsset ? 15 : 100}
427
+ className="overflow-hidden"
428
+ >
429
+ <div className="h-full overflow-hidden p-4">
430
+ {filteredAssets.length === 0 ? (
431
+ <div className="h-full flex items-center justify-center">
432
+ <EmptyState
433
+ icon={Package}
434
+ title="No artifacts found"
435
+ description={searchQuery ? `No results for "${searchQuery}"` : "Run a pipeline to generate artifacts"}
436
+ />
377
437
  </div>
378
- </div>
438
+ ) : viewMode === 'table' ? (
439
+ <AssetTable
440
+ assets={filteredAssets}
441
+ onSelect={setSelectedAsset}
442
+ sortConfig={sortConfig}
443
+ onSort={handleSort}
444
+ selectedAsset={selectedAsset}
445
+ />
446
+ ) : (
447
+ <AssetGrid
448
+ assets={filteredAssets}
449
+ onSelect={setSelectedAsset}
450
+ selectedAsset={selectedAsset}
451
+ />
452
+ )}
453
+ </div>
454
+ </Panel>
455
+ )}
379
456
 
380
- {/* Data View */}
381
- <div className="flex-1 min-h-0 overflow-hidden">
382
- <DataView
383
- items={filteredAssets}
384
- loading={loading}
385
- columns={columns}
386
- renderGrid={renderGrid}
387
- initialView={viewMode}
388
- emptyState={
389
- <EmptyState
390
- icon={Package}
391
- title="No artifacts found"
392
- description="Try adjusting your filters or run a pipeline to generate artifacts."
393
- />
394
- }
457
+ {/* Resizable Handle & Details Panel */}
458
+ {selectedAsset && (
459
+ <>
460
+ {!hideListWhenDetails && (
461
+ <PanelResizeHandle className="w-1.5 bg-slate-200 dark:bg-slate-700 hover:bg-blue-400 dark:hover:bg-blue-500 transition-colors flex items-center justify-center group cursor-col-resize">
462
+ <div className="w-0.5 h-8 bg-slate-300 dark:bg-slate-600 group-hover:bg-blue-300 dark:group-hover:bg-blue-400 rounded-full" />
463
+ </PanelResizeHandle>
464
+ )}
465
+ <Panel
466
+ defaultSize={hideListWhenDetails ? 100 : (detailsPanelExpanded ? 75 : 65)}
467
+ minSize={40}
468
+ className="overflow-hidden"
469
+ >
470
+ <div className="h-full overflow-hidden">
471
+ <AssetDetailsPanelExpanded
472
+ asset={selectedAsset}
473
+ onClose={() => setSelectedAsset(null)}
474
+ isExpanded={detailsPanelExpanded}
475
+ onToggleExpand={() => setDetailsPanelExpanded(!detailsPanelExpanded)}
476
+ hideList={hideListWhenDetails}
477
+ onToggleHideList={() => setHideListWhenDetails(!hideListWhenDetails)}
395
478
  />
396
479
  </div>
397
- </div>
398
- )}
399
- </div>
480
+ </Panel>
481
+ </>
482
+ )}
483
+ </PanelGroup>
484
+ </main>
400
485
  </div>
401
486
  </div>
487
+ );
488
+ }
489
+
490
+ // Stat Badge Component
491
+ function StatBadge({ icon: Icon, value, label, color = 'slate' }) {
492
+ const colors = {
493
+ slate: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
494
+ purple: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
495
+ blue: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
496
+ emerald: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400'
497
+ };
498
+
499
+ return (
500
+ <div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${colors[color]}`}>
501
+ <Icon size={14} />
502
+ <span className="font-bold">{typeof value === 'number' ? value.toLocaleString() : value}</span>
503
+ <span className="text-xs opacity-70">{label}</span>
504
+ </div>
505
+ );
506
+ }
507
+
508
+ // Explorer Components
509
+ function ExplorerProject({ name, data, expanded, onToggle, expandedPipelines, expandedRuns, onTogglePipeline, onToggleRun, onAssetSelect, typeFilter }) {
510
+ return (
511
+ <div className="mb-1">
512
+ <button
513
+ onClick={onToggle}
514
+ className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
515
+ >
516
+ <motion.div animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}>
517
+ <ChevronRight size={14} className="text-slate-400" />
518
+ </motion.div>
519
+ {expanded ? <FolderOpen size={16} className="text-blue-500" /> : <Folder size={16} className="text-blue-500" />}
520
+ <span className="flex-1 text-left text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
521
+ {name}
522
+ </span>
523
+ <span className="text-xs text-slate-400 bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-full">
524
+ {data.count}
525
+ </span>
526
+ </button>
527
+
528
+ <AnimatePresence>
529
+ {expanded && (
530
+ <motion.div
531
+ initial={{ height: 0, opacity: 0 }}
532
+ animate={{ height: 'auto', opacity: 1 }}
533
+ exit={{ height: 0, opacity: 0 }}
534
+ transition={{ duration: 0.15 }}
535
+ className="overflow-hidden pl-4"
536
+ >
537
+ {Object.entries(data.pipelines).map(([pipelineName, pipelineData]) => (
538
+ <ExplorerPipeline
539
+ key={pipelineName}
540
+ name={pipelineName}
541
+ data={pipelineData}
542
+ projectName={name}
543
+ expanded={expandedPipelines[`${name}-${pipelineName}`]}
544
+ expandedRuns={expandedRuns}
545
+ onToggle={() => onTogglePipeline(pipelineName)}
546
+ onToggleRun={(r) => onToggleRun(pipelineName, r)}
547
+ onAssetSelect={onAssetSelect}
548
+ typeFilter={typeFilter}
549
+ />
550
+ ))}
551
+ </motion.div>
552
+ )}
553
+ </AnimatePresence>
554
+ </div>
555
+ );
556
+ }
557
+
558
+ function ExplorerPipeline({ name, data, projectName, expanded, expandedRuns, onToggle, onToggleRun, onAssetSelect, typeFilter }) {
559
+ return (
560
+ <div className="mb-0.5">
561
+ <button
562
+ onClick={onToggle}
563
+ className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
564
+ >
565
+ <motion.div animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}>
566
+ <ChevronRight size={12} className="text-slate-400" />
567
+ </motion.div>
568
+ <Activity size={14} className="text-emerald-500" />
569
+ <span className="flex-1 text-left text-xs text-slate-600 dark:text-slate-400 truncate">
570
+ {name}
571
+ </span>
572
+ <span className="text-[10px] text-slate-400">
573
+ {data.count}
574
+ </span>
575
+ </button>
576
+
577
+ <AnimatePresence>
578
+ {expanded && (
579
+ <motion.div
580
+ initial={{ height: 0, opacity: 0 }}
581
+ animate={{ height: 'auto', opacity: 1 }}
582
+ exit={{ height: 0, opacity: 0 }}
583
+ transition={{ duration: 0.15 }}
584
+ className="overflow-hidden pl-4"
585
+ >
586
+ {Object.entries(data.runs).map(([runId, assets]) => (
587
+ <ExplorerRun
588
+ key={runId}
589
+ runId={runId}
590
+ assets={assets}
591
+ projectName={projectName}
592
+ pipelineName={name}
593
+ expanded={expandedRuns[`${projectName}-${name}-${runId}`]}
594
+ onToggle={() => onToggleRun(runId)}
595
+ onAssetSelect={onAssetSelect}
596
+ typeFilter={typeFilter}
597
+ />
598
+ ))}
599
+ </motion.div>
600
+ )}
601
+ </AnimatePresence>
602
+ </div>
603
+ );
604
+ }
605
+
606
+ function ExplorerRun({ runId, assets, expanded, onToggle, onAssetSelect, typeFilter }) {
607
+ const filteredAssets = typeFilter === 'all' ? assets : assets.filter(a => a.type === typeFilter);
608
+
609
+ return (
610
+ <div className="mb-0.5">
611
+ <button
612
+ onClick={onToggle}
613
+ className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
614
+ >
615
+ <motion.div animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}>
616
+ <ChevronRight size={10} className="text-slate-400" />
617
+ </motion.div>
618
+ <GitBranch size={12} className="text-slate-400" />
619
+ <span className="flex-1 text-left text-[10px] font-mono text-slate-500 truncate">
620
+ {runId?.substring(0, 12) || 'unknown'}
621
+ </span>
622
+ <span className="text-[10px] text-slate-400">
623
+ {filteredAssets.length}
624
+ </span>
625
+ </button>
626
+
627
+ <AnimatePresence>
628
+ {expanded && (
629
+ <motion.div
630
+ initial={{ height: 0, opacity: 0 }}
631
+ animate={{ height: 'auto', opacity: 1 }}
632
+ exit={{ height: 0, opacity: 0 }}
633
+ transition={{ duration: 0.15 }}
634
+ className="overflow-hidden pl-4"
635
+ >
636
+ {filteredAssets.map(asset => {
637
+ const config = getTypeConfig(asset.type);
638
+ const Icon = config.icon;
639
+ return (
640
+ <button
641
+ key={asset.artifact_id}
642
+ onClick={() => onAssetSelect(asset)}
643
+ className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
644
+ >
645
+ <Icon size={12} className={config.color} />
646
+ <span className="flex-1 text-left text-[11px] text-slate-600 dark:text-slate-400 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400">
647
+ {asset.name}
648
+ </span>
649
+ </button>
650
+ );
651
+ })}
652
+ </motion.div>
653
+ )}
654
+ </AnimatePresence>
655
+ </div>
656
+ );
657
+ }
658
+
659
+ // Table Component
660
+ function AssetTable({ assets, onSelect, sortConfig, onSort, selectedAsset }) {
661
+ return (
662
+ <div className="h-full flex flex-col bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden shadow-sm">
663
+ {/* Table Header */}
664
+ <div className="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-700 px-4 py-3 grid grid-cols-[80px,1fr,120px,200px,140px,140px,100px,50px] gap-4 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
665
+ <div>Type</div>
666
+ <SortableHeader label="Name" sortKey="name" sortConfig={sortConfig} onSort={onSort} />
667
+ <div>Step</div>
668
+ <div>Details</div>
669
+ <SortableHeader label="Pipeline" sortKey="pipeline_name" sortConfig={sortConfig} onSort={onSort} />
670
+ <div>Run</div>
671
+ <SortableHeader label="Created" sortKey="created_at" sortConfig={sortConfig} onSort={onSort} />
672
+ <div></div>
673
+ </div>
674
+
675
+ {/* Table Body */}
676
+ <div className="flex-1 overflow-y-auto">
677
+ {assets.map((asset, index) => (
678
+ <AssetRow
679
+ key={asset.artifact_id || index}
680
+ asset={asset}
681
+ onSelect={onSelect}
682
+ isEven={index % 2 === 0}
683
+ isSelected={selectedAsset?.artifact_id === asset.artifact_id}
684
+ />
685
+ ))}
402
686
  </div>
403
687
  </div>
404
688
  );
405
689
  }
406
690
 
407
- function StatCardCompact({ icon: Icon, label, value }) {
691
+ function SortableHeader({ label, sortKey, sortConfig, onSort }) {
692
+ const isActive = sortConfig.key === sortKey;
693
+ return (
694
+ <button
695
+ onClick={() => onSort(sortKey)}
696
+ className="flex items-center gap-1 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
697
+ >
698
+ {label}
699
+ <ArrowUpDown size={12} className={isActive ? 'text-blue-500' : 'opacity-30'} />
700
+ </button>
701
+ );
702
+ }
703
+
704
+ function AssetRow({ asset, onSelect, isEven, isSelected }) {
705
+ const config = getTypeConfig(asset.type);
706
+ const Icon = config.icon;
707
+ const props = asset.properties || {};
708
+
709
+ const getQuickInfo = () => {
710
+ if (asset.type === 'Model') {
711
+ const parts = [];
712
+ if (props.framework) parts.push(props.framework);
713
+ if (props.parameters) {
714
+ const p = props.parameters;
715
+ parts.push(p >= 1000000 ? `${(p/1000000).toFixed(1)}M params` : p >= 1000 ? `${(p/1000).toFixed(0)}K params` : `${p} params`);
716
+ }
717
+ return parts.join(' • ') || '-';
718
+ }
719
+ if (asset.type === 'Dataset') {
720
+ const parts = [];
721
+ const samples = props.samples || props.num_samples;
722
+ if (samples) parts.push(`${samples.toLocaleString()} rows`);
723
+ if (props.num_features) parts.push(`${props.num_features} cols`);
724
+ return parts.join(' • ') || '-';
725
+ }
726
+ if (asset.type === 'Metrics') {
727
+ const metricKeys = Object.keys(props).filter(k => !k.startsWith('_') && typeof props[k] === 'number').slice(0, 2);
728
+ return metricKeys.map(k => `${k}: ${props[k] < 0.01 ? props[k].toExponential(1) : props[k].toFixed(3)}`).join(' • ') || '-';
729
+ }
730
+ return '-';
731
+ };
732
+
408
733
  return (
409
- <div className="bg-white dark:bg-slate-800 rounded-xl p-3 border border-slate-200 dark:border-slate-700 shadow-sm">
410
- <div className="flex items-center gap-2 mb-1">
411
- <Icon size={14} className="text-slate-400" />
412
- <span className="text-xs text-slate-500 dark:text-slate-400">{label}</span>
734
+ <motion.div
735
+ initial={{ opacity: 0, y: 5 }}
736
+ animate={{ opacity: 1, y: 0 }}
737
+ onClick={() => onSelect(asset)}
738
+ className={`px-4 py-3 grid grid-cols-[80px,1fr,120px,200px,140px,140px,100px,50px] gap-4 items-center cursor-pointer transition-colors border-b border-slate-100 dark:border-slate-700/50 group ${
739
+ isSelected
740
+ ? 'bg-blue-100 dark:bg-blue-900/40 border-l-4 border-l-blue-500'
741
+ : `hover:bg-blue-50 dark:hover:bg-blue-900/20 ${isEven ? '' : 'bg-slate-50/50 dark:bg-slate-800/30'}`
742
+ }`}
743
+ >
744
+ {/* Type */}
745
+ <div className={`flex items-center gap-2 px-2 py-1 rounded-lg w-fit ${config.bg}`}>
746
+ <Icon size={14} className={config.color} />
747
+ <span className={`text-xs font-medium ${config.color}`}>{asset.type}</span>
748
+ </div>
749
+
750
+ {/* Name */}
751
+ <div className="truncate">
752
+ <span className="font-medium text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
753
+ {asset.name}
754
+ </span>
755
+ </div>
756
+
757
+ {/* Step */}
758
+ <span className="text-xs font-mono text-slate-500 truncate">
759
+ {asset.step || '-'}
760
+ </span>
761
+
762
+ {/* Details */}
763
+ <span className="text-xs text-slate-500 truncate">
764
+ {getQuickInfo()}
765
+ </span>
766
+
767
+ {/* Pipeline */}
768
+ <span className="text-xs text-slate-600 dark:text-slate-400 truncate">
769
+ {asset.pipeline_name || '-'}
770
+ </span>
771
+
772
+ {/* Run */}
773
+ {asset.run_id ? (
774
+ <Link
775
+ to={`/runs/${asset.run_id}`}
776
+ onClick={(e) => e.stopPropagation()}
777
+ className="text-xs font-mono text-blue-600 hover:underline truncate"
778
+ >
779
+ {asset.run_id?.substring(0, 12)}
780
+ </Link>
781
+ ) : (
782
+ <span className="text-xs text-slate-400">-</span>
783
+ )}
784
+
785
+ {/* Created */}
786
+ <span className="text-xs text-slate-500">
787
+ {asset.created_at ? formatDistanceToNow(new Date(asset.created_at), { addSuffix: true }).replace('about ', '') : '-'}
788
+ </span>
789
+
790
+ {/* Actions */}
791
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
792
+ <button
793
+ onClick={(e) => { e.stopPropagation(); onSelect(asset); }}
794
+ className="p-1 hover:bg-slate-200 dark:hover:bg-slate-600 rounded"
795
+ title="View Details"
796
+ >
797
+ <Eye size={14} className="text-slate-500" />
798
+ </button>
799
+ <button
800
+ onClick={(e) => { e.stopPropagation(); downloadArtifactById(asset.artifact_id); }}
801
+ className="p-1 hover:bg-slate-200 dark:hover:bg-slate-600 rounded"
802
+ title="Download"
803
+ >
804
+ <Download size={14} className="text-slate-500" />
805
+ </button>
806
+ </div>
807
+ </motion.div>
808
+ );
809
+ }
810
+
811
+ // Grid Component
812
+ function AssetGrid({ assets, onSelect, selectedAsset }) {
813
+ return (
814
+ <div className="h-full overflow-y-auto">
815
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 p-1">
816
+ {assets.map((asset, index) => (
817
+ <AssetCard
818
+ key={asset.artifact_id || index}
819
+ asset={asset}
820
+ onSelect={onSelect}
821
+ isSelected={selectedAsset?.artifact_id === asset.artifact_id}
822
+ />
823
+ ))}
413
824
  </div>
414
- <p className="text-lg font-semibold text-slate-900 dark:text-white">
415
- {typeof value === 'number' ? value.toLocaleString() : value}
416
- </p>
417
825
  </div>
418
826
  );
419
827
  }
420
828
 
421
- function formatBytes(bytes) {
422
- if (!bytes || bytes === 0) return '0 B';
423
- const k = 1024;
424
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
425
- const i = Math.floor(Math.log(bytes) / Math.log(k));
426
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
829
+ function AssetCard({ asset, onSelect, isSelected }) {
830
+ const config = getTypeConfig(asset.type);
831
+ const Icon = config.icon;
832
+ const props = asset.properties || {};
833
+
834
+ const getQuickStats = () => {
835
+ if (asset.type === 'Model') {
836
+ return {
837
+ primary: props.framework || 'Unknown',
838
+ secondary: props.parameters ? (props.parameters >= 1000000 ? `${(props.parameters/1000000).toFixed(1)}M` : `${(props.parameters/1000).toFixed(0)}K`) : null,
839
+ label: 'params'
840
+ };
841
+ }
842
+ if (asset.type === 'Dataset') {
843
+ return {
844
+ primary: (props.samples || props.num_samples)?.toLocaleString() || '?',
845
+ secondary: props.num_features,
846
+ label: props.num_features ? 'cols' : 'rows'
847
+ };
848
+ }
849
+ if (asset.type === 'Metrics') {
850
+ const metricKey = Object.keys(props).find(k => !k.startsWith('_') && typeof props[k] === 'number');
851
+ return metricKey ? {
852
+ primary: props[metricKey] < 0.01 ? props[metricKey].toExponential(1) : props[metricKey].toFixed(3),
853
+ secondary: null,
854
+ label: metricKey
855
+ } : { primary: '-', secondary: null, label: '' };
856
+ }
857
+ return { primary: '-', secondary: null, label: '' };
858
+ };
859
+
860
+ const stats = getQuickStats();
861
+
862
+ return (
863
+ <motion.div
864
+ initial={{ opacity: 0, scale: 0.95 }}
865
+ animate={{ opacity: 1, scale: 1 }}
866
+ whileHover={{ y: -4, scale: 1.02 }}
867
+ transition={{ duration: 0.2 }}
868
+ >
869
+ <Card
870
+ onClick={() => onSelect(asset)}
871
+ className={`cursor-pointer group relative overflow-hidden bg-white dark:bg-slate-800 transition-all ${
872
+ isSelected
873
+ ? 'border-2 border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800 shadow-xl'
874
+ : 'border border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-600 hover:shadow-lg'
875
+ }`}
876
+ >
877
+ {/* Gradient Background */}
878
+ <div className={`absolute inset-0 bg-gradient-to-br ${config.gradient} opacity-5 group-hover:opacity-10 transition-opacity`} />
879
+
880
+ <div className="relative p-4">
881
+ {/* Header */}
882
+ <div className="flex items-start justify-between mb-3">
883
+ <div className={`p-2.5 rounded-xl bg-gradient-to-br ${config.gradient} text-white shadow-lg group-hover:shadow-xl transition-shadow`}>
884
+ <Icon size={20} />
885
+ </div>
886
+ <Badge variant="outline" className={`text-[10px] px-2 py-0.5 ${config.borderColor} ${config.color}`}>
887
+ {asset.type}
888
+ </Badge>
889
+ </div>
890
+
891
+ {/* Name */}
892
+ <h4 className="font-semibold text-slate-900 dark:text-white mb-2 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
893
+ {asset.name}
894
+ </h4>
895
+
896
+ {/* Quick Stats */}
897
+ <div className={`${config.bg} rounded-lg p-2 mb-3`}>
898
+ <div className="flex items-baseline gap-1.5">
899
+ <span className={`text-lg font-bold ${config.color}`}>
900
+ {stats.primary}
901
+ </span>
902
+ {stats.secondary && (
903
+ <span className="text-xs text-slate-500">
904
+ × {stats.secondary}
905
+ </span>
906
+ )}
907
+ <span className="text-[10px] text-slate-500 ml-auto">
908
+ {stats.label}
909
+ </span>
910
+ </div>
911
+ </div>
912
+
913
+ {/* Metadata */}
914
+ <div className="flex items-center justify-between text-[10px] text-slate-500">
915
+ <span className="truncate max-w-[100px]">{asset.step || 'N/A'}</span>
916
+ <span>{asset.created_at ? format(new Date(asset.created_at), 'MMM d') : '-'}</span>
917
+ </div>
918
+
919
+ {/* Run ID */}
920
+ {asset.run_id && (
921
+ <div className="mt-2 pt-2 border-t border-slate-100 dark:border-slate-700">
922
+ <span className="text-[10px] font-mono text-slate-400 bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded">
923
+ {asset.run_id?.substring(0, 10)}
924
+ </span>
925
+ </div>
926
+ )}
927
+ </div>
928
+ </Card>
929
+ </motion.div>
930
+ );
931
+ }
932
+
933
+ // Expanded Details Panel Wrapper with controls
934
+ function AssetDetailsPanelExpanded({ asset, onClose, isExpanded, onToggleExpand, hideList, onToggleHideList }) {
935
+ const config = getTypeConfig(asset.type);
936
+ const Icon = config.icon;
937
+
938
+ return (
939
+ <div className="h-full flex flex-col bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-700">
940
+ {/* Custom Header with expand controls */}
941
+ <div className={`bg-gradient-to-r ${config.gradient} p-4`}>
942
+ <div className="flex items-center justify-between">
943
+ <div className="flex items-center gap-3">
944
+ <div className="p-2.5 bg-white/20 backdrop-blur-sm rounded-xl">
945
+ <Icon size={24} className="text-white" />
946
+ </div>
947
+ <div>
948
+ <h2 className="text-lg font-bold text-white truncate max-w-[400px]">
949
+ {asset.name}
950
+ </h2>
951
+ <p className="text-white/80 text-sm">
952
+ {asset.type} • {asset.step || 'No step'}
953
+ </p>
954
+ </div>
955
+ </div>
956
+ <div className="flex items-center gap-2">
957
+ {/* Toggle hide list button */}
958
+ <button
959
+ onClick={onToggleHideList}
960
+ className="p-2 bg-white/20 hover:bg-white/30 rounded-lg transition-colors"
961
+ title={hideList ? "Show list" : "Hide list for full view"}
962
+ >
963
+ {hideList ? <Minimize2 size={18} className="text-white" /> : <Maximize2 size={18} className="text-white" />}
964
+ </button>
965
+ <Button
966
+ onClick={onToggleExpand}
967
+ variant="ghost"
968
+ size="sm"
969
+ className="text-white/80 hover:text-white hover:bg-white/20"
970
+ title={isExpanded ? "Collapse panel" : "Expand panel"}
971
+ >
972
+ {isExpanded ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
973
+ </Button>
974
+ <Button
975
+ onClick={onClose}
976
+ variant="ghost"
977
+ size="sm"
978
+ className="text-white/80 hover:text-white hover:bg-white/20"
979
+ >
980
+ <X size={18} />
981
+ </Button>
982
+ </div>
983
+ </div>
984
+ </div>
985
+
986
+ {/* Content - use the existing AssetDetailsPanel but without header */}
987
+ <div className="flex-1 overflow-hidden">
988
+ <AssetDetailsPanel
989
+ asset={asset}
990
+ onClose={onClose}
991
+ hideHeader={true}
992
+ />
993
+ </div>
994
+ </div>
995
+ );
427
996
  }