flowyml 1.7.0__py3-none-any.whl → 1.7.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowyml/assets/dataset.py +570 -17
- flowyml/assets/model.py +1052 -15
- flowyml/core/executor.py +70 -11
- flowyml/core/orchestrator.py +37 -2
- flowyml/core/pipeline.py +32 -4
- flowyml/core/scheduler.py +88 -5
- flowyml/integrations/keras.py +247 -82
- flowyml/storage/sql.py +24 -6
- flowyml/ui/backend/routers/runs.py +112 -0
- flowyml/ui/backend/routers/schedules.py +35 -15
- flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +1 -0
- flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +685 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/package-lock.json +11 -0
- flowyml/ui/frontend/package.json +1 -0
- flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
- flowyml/ui/frontend/src/app/dashboard/page.jsx +1 -1
- flowyml/ui/frontend/src/app/experiments/[experimentId]/page.jsx +1 -1
- flowyml/ui/frontend/src/app/leaderboard/page.jsx +1 -1
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +3 -3
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +590 -102
- flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +401 -28
- flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
- flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
- flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
- flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/METADATA +1 -1
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/RECORD +33 -30
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
- flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +0 -630
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/WHEEL +0 -0
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/entry_points.txt +0 -0
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,45 +1,124 @@
|
|
|
1
|
-
|
|
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 {
|
|
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 {
|
|
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');
|
|
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
|
|
93
|
+
const fetchData = async () => {
|
|
32
94
|
setLoading(true);
|
|
33
95
|
setError(null);
|
|
34
96
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
129
|
+
fetchData();
|
|
51
130
|
}, [selectedProject]);
|
|
52
131
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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)}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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="
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
<
|
|
282
|
-
<div className="flex items-center justify-between max-w-[
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
{
|
|
291
|
-
|
|
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('
|
|
294
|
-
className={`p-
|
|
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
|
|
297
|
-
|
|
298
|
-
title="
|
|
304
|
+
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
|
|
305
|
+
}`}
|
|
306
|
+
title="Table View"
|
|
299
307
|
>
|
|
300
|
-
<
|
|
308
|
+
<List size={18} />
|
|
301
309
|
</button>
|
|
302
310
|
<button
|
|
303
|
-
onClick={() => setViewMode('
|
|
304
|
-
className={`p-
|
|
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
|
|
314
|
+
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
|
|
307
315
|
}`}
|
|
308
|
-
title="
|
|
316
|
+
title="Grid View"
|
|
309
317
|
>
|
|
310
|
-
<
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
<
|
|
323
|
-
{
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
</
|
|
415
|
+
</motion.aside>
|
|
416
|
+
)}
|
|
417
|
+
</AnimatePresence>
|
|
351
418
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
<div className="flex
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
</
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
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
|
}
|