flowyml 1.7.1__py3-none-any.whl → 1.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowyml/assets/base.py +15 -0
- flowyml/assets/dataset.py +570 -17
- flowyml/assets/metrics.py +5 -0
- flowyml/assets/model.py +1052 -15
- flowyml/cli/main.py +709 -0
- flowyml/cli/stack_cli.py +138 -25
- flowyml/core/__init__.py +17 -0
- flowyml/core/executor.py +231 -37
- flowyml/core/image_builder.py +129 -0
- flowyml/core/log_streamer.py +227 -0
- flowyml/core/orchestrator.py +59 -4
- flowyml/core/pipeline.py +65 -13
- flowyml/core/routing.py +558 -0
- flowyml/core/scheduler.py +88 -5
- flowyml/core/step.py +9 -1
- flowyml/core/step_grouping.py +49 -35
- flowyml/core/types.py +407 -0
- flowyml/integrations/keras.py +247 -82
- flowyml/monitoring/alerts.py +10 -0
- flowyml/monitoring/notifications.py +104 -25
- flowyml/monitoring/slack_blocks.py +323 -0
- flowyml/plugins/__init__.py +251 -0
- flowyml/plugins/alerters/__init__.py +1 -0
- flowyml/plugins/alerters/slack.py +168 -0
- flowyml/plugins/base.py +752 -0
- flowyml/plugins/config.py +478 -0
- flowyml/plugins/deployers/__init__.py +22 -0
- flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
- flowyml/plugins/deployers/sagemaker.py +306 -0
- flowyml/plugins/deployers/vertex.py +290 -0
- flowyml/plugins/integration.py +369 -0
- flowyml/plugins/manager.py +510 -0
- flowyml/plugins/model_registries/__init__.py +22 -0
- flowyml/plugins/model_registries/mlflow.py +159 -0
- flowyml/plugins/model_registries/sagemaker.py +489 -0
- flowyml/plugins/model_registries/vertex.py +386 -0
- flowyml/plugins/orchestrators/__init__.py +13 -0
- flowyml/plugins/orchestrators/sagemaker.py +443 -0
- flowyml/plugins/orchestrators/vertex_ai.py +461 -0
- flowyml/plugins/registries/__init__.py +13 -0
- flowyml/plugins/registries/ecr.py +321 -0
- flowyml/plugins/registries/gcr.py +313 -0
- flowyml/plugins/registry.py +454 -0
- flowyml/plugins/stack.py +494 -0
- flowyml/plugins/stack_config.py +537 -0
- flowyml/plugins/stores/__init__.py +13 -0
- flowyml/plugins/stores/gcs.py +460 -0
- flowyml/plugins/stores/s3.py +453 -0
- flowyml/plugins/trackers/__init__.py +11 -0
- flowyml/plugins/trackers/mlflow.py +316 -0
- flowyml/plugins/validators/__init__.py +3 -0
- flowyml/plugins/validators/deepchecks.py +119 -0
- flowyml/registry/__init__.py +2 -1
- flowyml/registry/model_environment.py +109 -0
- flowyml/registry/model_registry.py +241 -96
- flowyml/serving/__init__.py +17 -0
- flowyml/serving/model_server.py +628 -0
- flowyml/stacks/__init__.py +60 -0
- flowyml/stacks/aws.py +93 -0
- flowyml/stacks/base.py +62 -0
- flowyml/stacks/components.py +12 -0
- flowyml/stacks/gcp.py +44 -9
- flowyml/stacks/plugins.py +115 -0
- flowyml/stacks/registry.py +2 -1
- flowyml/storage/sql.py +401 -12
- flowyml/tracking/experiment.py +8 -5
- flowyml/ui/backend/Dockerfile +87 -16
- flowyml/ui/backend/auth.py +12 -2
- flowyml/ui/backend/main.py +149 -5
- flowyml/ui/backend/routers/ai_context.py +226 -0
- flowyml/ui/backend/routers/assets.py +23 -4
- flowyml/ui/backend/routers/auth.py +96 -0
- flowyml/ui/backend/routers/deployments.py +660 -0
- flowyml/ui/backend/routers/model_explorer.py +597 -0
- flowyml/ui/backend/routers/plugins.py +103 -51
- flowyml/ui/backend/routers/projects.py +91 -8
- flowyml/ui/backend/routers/runs.py +132 -1
- flowyml/ui/backend/routers/schedules.py +54 -29
- flowyml/ui/backend/routers/templates.py +319 -0
- flowyml/ui/backend/routers/websocket.py +2 -2
- flowyml/ui/frontend/Dockerfile +55 -6
- flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
- flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/dist/logo.png +0 -0
- flowyml/ui/frontend/nginx.conf +65 -4
- flowyml/ui/frontend/package-lock.json +1415 -74
- flowyml/ui/frontend/package.json +4 -0
- flowyml/ui/frontend/public/logo.png +0 -0
- flowyml/ui/frontend/src/App.jsx +10 -7
- flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
- flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
- flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
- flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
- flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
- flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +601 -101
- flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
- flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
- flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +424 -29
- flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
- flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
- flowyml/ui/frontend/src/components/Layout.jsx +6 -0
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
- flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
- flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
- flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
- flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
- flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
- flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
- flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
- flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
- flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
- flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
- flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
- flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
- flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
- flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
- flowyml/ui/frontend/src/router/index.jsx +47 -20
- flowyml/ui/frontend/src/services/pluginService.js +3 -1
- flowyml/ui/server_manager.py +5 -5
- flowyml/ui/utils.py +157 -39
- flowyml/utils/config.py +37 -15
- flowyml/utils/model_introspection.py +123 -0
- flowyml/utils/observability.py +30 -0
- flowyml-1.8.0.dist-info/METADATA +174 -0
- {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/RECORD +134 -73
- {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
- flowyml/ui/frontend/dist/assets/index-BqDQvp63.js +0 -630
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
- flowyml-1.7.1.dist-info/METADATA +0 -477
- {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
- {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
BarChart,
|
|
4
|
+
Bar,
|
|
5
|
+
XAxis,
|
|
6
|
+
YAxis,
|
|
7
|
+
CartesianGrid,
|
|
8
|
+
Tooltip,
|
|
9
|
+
ResponsiveContainer,
|
|
10
|
+
PieChart,
|
|
11
|
+
Pie,
|
|
12
|
+
Cell,
|
|
13
|
+
Legend,
|
|
14
|
+
} from 'recharts';
|
|
15
|
+
import {
|
|
16
|
+
Database,
|
|
17
|
+
BarChart3,
|
|
18
|
+
Table,
|
|
19
|
+
Hash,
|
|
20
|
+
Type,
|
|
21
|
+
Calendar,
|
|
22
|
+
AlertTriangle,
|
|
23
|
+
ChevronDown,
|
|
24
|
+
ChevronRight,
|
|
25
|
+
Eye,
|
|
26
|
+
Columns,
|
|
27
|
+
Rows,
|
|
28
|
+
Info,
|
|
29
|
+
Activity,
|
|
30
|
+
PieChart as PieChartIcon,
|
|
31
|
+
} from 'lucide-react';
|
|
32
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
33
|
+
|
|
34
|
+
// Color palette for charts
|
|
35
|
+
const COLORS = [
|
|
36
|
+
'#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444',
|
|
37
|
+
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Custom tooltip for histograms
|
|
41
|
+
const HistogramTooltip = ({ active, payload, label }) => {
|
|
42
|
+
if (active && payload && payload.length) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="bg-white/95 backdrop-blur-lg p-3 rounded-lg shadow-xl border border-slate-200/50">
|
|
45
|
+
<p className="font-medium text-slate-900 text-sm">{label}</p>
|
|
46
|
+
<p className="text-primary-600 text-sm font-mono">
|
|
47
|
+
Count: <span className="font-bold">{payload[0].value}</span>
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Statistic card component
|
|
56
|
+
function StatCard({ icon: Icon, label, value, subValue, color = 'blue' }) {
|
|
57
|
+
const colorClasses = {
|
|
58
|
+
blue: 'from-blue-500 to-blue-600',
|
|
59
|
+
purple: 'from-purple-500 to-purple-600',
|
|
60
|
+
emerald: 'from-emerald-500 to-emerald-600',
|
|
61
|
+
amber: 'from-amber-500 to-amber-600',
|
|
62
|
+
rose: 'from-rose-500 to-rose-600',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<motion.div
|
|
67
|
+
initial={{ opacity: 0, y: 10 }}
|
|
68
|
+
animate={{ opacity: 1, y: 0 }}
|
|
69
|
+
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow"
|
|
70
|
+
>
|
|
71
|
+
<div className="flex items-center gap-3">
|
|
72
|
+
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorClasses[color]} shadow-sm`}>
|
|
73
|
+
<Icon size={16} className="text-white" />
|
|
74
|
+
</div>
|
|
75
|
+
<div>
|
|
76
|
+
<p className="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide">{label}</p>
|
|
77
|
+
<p className="text-lg font-bold text-slate-900 dark:text-white font-mono">{value}</p>
|
|
78
|
+
{subValue && (
|
|
79
|
+
<p className="text-xs text-slate-400 dark:text-slate-500">{subValue}</p>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</motion.div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Column card with expandable histogram
|
|
88
|
+
function ColumnCard({ column, index, data }) {
|
|
89
|
+
const [expanded, setExpanded] = useState(false);
|
|
90
|
+
const color = COLORS[index % COLORS.length];
|
|
91
|
+
|
|
92
|
+
// Calculate statistics for numeric columns
|
|
93
|
+
const stats = useMemo(() => {
|
|
94
|
+
if (!data || !column.values) return null;
|
|
95
|
+
|
|
96
|
+
const values = column.values.filter(v => v !== null && v !== undefined && !isNaN(v));
|
|
97
|
+
if (values.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
const numericValues = values.map(Number).filter(v => !isNaN(v));
|
|
100
|
+
if (numericValues.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
const sum = numericValues.reduce((a, b) => a + b, 0);
|
|
103
|
+
const mean = sum / numericValues.length;
|
|
104
|
+
const sorted = [...numericValues].sort((a, b) => a - b);
|
|
105
|
+
const min = sorted[0];
|
|
106
|
+
const max = sorted[sorted.length - 1];
|
|
107
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
108
|
+
const variance = numericValues.reduce((acc, v) => acc + Math.pow(v - mean, 2), 0) / numericValues.length;
|
|
109
|
+
const std = Math.sqrt(variance);
|
|
110
|
+
|
|
111
|
+
return { mean, min, max, median, std, count: numericValues.length };
|
|
112
|
+
}, [column, data]);
|
|
113
|
+
|
|
114
|
+
// Generate histogram data
|
|
115
|
+
const histogramData = useMemo(() => {
|
|
116
|
+
if (!stats || !column.values) return [];
|
|
117
|
+
|
|
118
|
+
const values = column.values.map(Number).filter(v => !isNaN(v));
|
|
119
|
+
const binCount = Math.min(20, Math.max(5, Math.ceil(Math.sqrt(values.length))));
|
|
120
|
+
const range = stats.max - stats.min;
|
|
121
|
+
const binSize = range / binCount || 1;
|
|
122
|
+
|
|
123
|
+
const bins = Array(binCount).fill(0).map((_, i) => ({
|
|
124
|
+
range: `${(stats.min + i * binSize).toFixed(2)}`,
|
|
125
|
+
count: 0,
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
values.forEach(v => {
|
|
129
|
+
const binIndex = Math.min(Math.floor((v - stats.min) / binSize), binCount - 1);
|
|
130
|
+
if (binIndex >= 0 && binIndex < binCount) {
|
|
131
|
+
bins[binIndex].count++;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return bins;
|
|
136
|
+
}, [column, stats]);
|
|
137
|
+
|
|
138
|
+
const isNumeric = stats !== null;
|
|
139
|
+
const uniqueCount = column.values ? new Set(column.values).size : 0;
|
|
140
|
+
const nullCount = column.values ? column.values.filter(v => v === null || v === undefined || v === '').length : 0;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<motion.div
|
|
144
|
+
layout
|
|
145
|
+
initial={{ opacity: 0, y: 10 }}
|
|
146
|
+
animate={{ opacity: 1, y: 0 }}
|
|
147
|
+
transition={{ delay: index * 0.05 }}
|
|
148
|
+
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"
|
|
149
|
+
>
|
|
150
|
+
{/* Column Header */}
|
|
151
|
+
<button
|
|
152
|
+
onClick={() => setExpanded(!expanded)}
|
|
153
|
+
className="w-full p-4 flex items-center justify-between hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
|
154
|
+
>
|
|
155
|
+
<div className="flex items-center gap-3">
|
|
156
|
+
<div
|
|
157
|
+
className="w-3 h-3 rounded-full"
|
|
158
|
+
style={{ backgroundColor: color }}
|
|
159
|
+
/>
|
|
160
|
+
<div className="text-left">
|
|
161
|
+
<h4 className="font-semibold text-slate-900 dark:text-white">{column.name}</h4>
|
|
162
|
+
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
|
163
|
+
<span className="flex items-center gap-1">
|
|
164
|
+
{isNumeric ? <Hash size={10} /> : <Type size={10} />}
|
|
165
|
+
{isNumeric ? 'Numeric' : 'Categorical'}
|
|
166
|
+
</span>
|
|
167
|
+
<span>•</span>
|
|
168
|
+
<span>{uniqueCount} unique</span>
|
|
169
|
+
{nullCount > 0 && (
|
|
170
|
+
<>
|
|
171
|
+
<span>•</span>
|
|
172
|
+
<span className="text-amber-600 flex items-center gap-1">
|
|
173
|
+
<AlertTriangle size={10} />
|
|
174
|
+
{nullCount} null
|
|
175
|
+
</span>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
|
182
|
+
</button>
|
|
183
|
+
|
|
184
|
+
{/* Expanded Content */}
|
|
185
|
+
<AnimatePresence>
|
|
186
|
+
{expanded && (
|
|
187
|
+
<motion.div
|
|
188
|
+
initial={{ height: 0, opacity: 0 }}
|
|
189
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
190
|
+
exit={{ height: 0, opacity: 0 }}
|
|
191
|
+
transition={{ duration: 0.2 }}
|
|
192
|
+
className="overflow-hidden"
|
|
193
|
+
>
|
|
194
|
+
<div className="p-4 pt-0 border-t border-slate-100 dark:border-slate-700">
|
|
195
|
+
{isNumeric && stats && (
|
|
196
|
+
<>
|
|
197
|
+
{/* Statistics Grid */}
|
|
198
|
+
<div className="grid grid-cols-3 gap-2 mb-4">
|
|
199
|
+
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-2 text-center">
|
|
200
|
+
<p className="text-xs text-slate-500">Mean</p>
|
|
201
|
+
<p className="font-mono font-bold text-slate-900 dark:text-white">
|
|
202
|
+
{stats.mean.toFixed(4)}
|
|
203
|
+
</p>
|
|
204
|
+
</div>
|
|
205
|
+
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-2 text-center">
|
|
206
|
+
<p className="text-xs text-slate-500">Std Dev</p>
|
|
207
|
+
<p className="font-mono font-bold text-slate-900 dark:text-white">
|
|
208
|
+
{stats.std.toFixed(4)}
|
|
209
|
+
</p>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-2 text-center">
|
|
212
|
+
<p className="text-xs text-slate-500">Median</p>
|
|
213
|
+
<p className="font-mono font-bold text-slate-900 dark:text-white">
|
|
214
|
+
{stats.median.toFixed(4)}
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-2 text-center">
|
|
218
|
+
<p className="text-xs text-slate-500">Min</p>
|
|
219
|
+
<p className="font-mono font-bold text-emerald-600">
|
|
220
|
+
{stats.min.toFixed(4)}
|
|
221
|
+
</p>
|
|
222
|
+
</div>
|
|
223
|
+
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-2 text-center">
|
|
224
|
+
<p className="text-xs text-slate-500">Max</p>
|
|
225
|
+
<p className="font-mono font-bold text-rose-600">
|
|
226
|
+
{stats.max.toFixed(4)}
|
|
227
|
+
</p>
|
|
228
|
+
</div>
|
|
229
|
+
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-2 text-center">
|
|
230
|
+
<p className="text-xs text-slate-500">Count</p>
|
|
231
|
+
<p className="font-mono font-bold text-slate-900 dark:text-white">
|
|
232
|
+
{stats.count}
|
|
233
|
+
</p>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Histogram */}
|
|
238
|
+
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-3">
|
|
239
|
+
<h5 className="text-xs font-semibold text-slate-600 dark:text-slate-400 mb-2 flex items-center gap-1">
|
|
240
|
+
<BarChart3 size={12} /> Distribution
|
|
241
|
+
</h5>
|
|
242
|
+
<ResponsiveContainer width="100%" height={120}>
|
|
243
|
+
<BarChart data={histogramData}>
|
|
244
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" strokeOpacity={0.5} />
|
|
245
|
+
<XAxis
|
|
246
|
+
dataKey="range"
|
|
247
|
+
tick={{ fontSize: 9 }}
|
|
248
|
+
tickLine={false}
|
|
249
|
+
interval="preserveStartEnd"
|
|
250
|
+
/>
|
|
251
|
+
<YAxis tick={{ fontSize: 9 }} tickLine={false} width={30} />
|
|
252
|
+
<Tooltip content={<HistogramTooltip />} />
|
|
253
|
+
<Bar dataKey="count" fill={color} radius={[2, 2, 0, 0]} />
|
|
254
|
+
</BarChart>
|
|
255
|
+
</ResponsiveContainer>
|
|
256
|
+
</div>
|
|
257
|
+
</>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{!isNumeric && column.values && (
|
|
261
|
+
<CategoricalDistribution values={column.values} color={color} />
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
</motion.div>
|
|
265
|
+
)}
|
|
266
|
+
</AnimatePresence>
|
|
267
|
+
</motion.div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Categorical distribution component
|
|
272
|
+
function CategoricalDistribution({ values, color }) {
|
|
273
|
+
const distribution = useMemo(() => {
|
|
274
|
+
const counts = {};
|
|
275
|
+
values.forEach(v => {
|
|
276
|
+
const key = v === null || v === undefined ? '(null)' : String(v);
|
|
277
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return Object.entries(counts)
|
|
281
|
+
.sort((a, b) => b[1] - a[1])
|
|
282
|
+
.slice(0, 10)
|
|
283
|
+
.map(([name, count], idx) => ({
|
|
284
|
+
name: name.length > 15 ? name.substring(0, 15) + '...' : name,
|
|
285
|
+
count,
|
|
286
|
+
fill: COLORS[idx % COLORS.length],
|
|
287
|
+
}));
|
|
288
|
+
}, [values]);
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-3">
|
|
292
|
+
<h5 className="text-xs font-semibold text-slate-600 dark:text-slate-400 mb-2 flex items-center gap-1">
|
|
293
|
+
<PieChartIcon size={12} /> Top Values
|
|
294
|
+
</h5>
|
|
295
|
+
<ResponsiveContainer width="100%" height={150}>
|
|
296
|
+
<BarChart data={distribution} layout="vertical">
|
|
297
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" strokeOpacity={0.5} />
|
|
298
|
+
<XAxis type="number" tick={{ fontSize: 9 }} />
|
|
299
|
+
<YAxis dataKey="name" type="category" tick={{ fontSize: 9 }} width={80} />
|
|
300
|
+
<Tooltip content={<HistogramTooltip />} />
|
|
301
|
+
<Bar dataKey="count" radius={[0, 2, 2, 0]}>
|
|
302
|
+
{distribution.map((entry, index) => (
|
|
303
|
+
<Cell key={`cell-${index}`} fill={entry.fill} />
|
|
304
|
+
))}
|
|
305
|
+
</Bar>
|
|
306
|
+
</BarChart>
|
|
307
|
+
</ResponsiveContainer>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Sample data table
|
|
313
|
+
function SampleDataTable({ data, columns, maxRows = 10 }) {
|
|
314
|
+
if (!data || !columns || columns.length === 0) return null;
|
|
315
|
+
|
|
316
|
+
const sampleData = data.slice(0, maxRows);
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
|
|
320
|
+
<table className="w-full text-xs">
|
|
321
|
+
<thead className="bg-slate-100 dark:bg-slate-800">
|
|
322
|
+
<tr>
|
|
323
|
+
<th className="px-3 py-2 text-left text-slate-600 dark:text-slate-400 font-semibold border-b border-slate-200 dark:border-slate-700">
|
|
324
|
+
#
|
|
325
|
+
</th>
|
|
326
|
+
{columns.map((col, idx) => (
|
|
327
|
+
<th
|
|
328
|
+
key={idx}
|
|
329
|
+
className="px-3 py-2 text-left text-slate-600 dark:text-slate-400 font-semibold border-b border-slate-200 dark:border-slate-700"
|
|
330
|
+
>
|
|
331
|
+
{col}
|
|
332
|
+
</th>
|
|
333
|
+
))}
|
|
334
|
+
</tr>
|
|
335
|
+
</thead>
|
|
336
|
+
<tbody>
|
|
337
|
+
{sampleData.map((row, rowIdx) => (
|
|
338
|
+
<tr
|
|
339
|
+
key={rowIdx}
|
|
340
|
+
className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"
|
|
341
|
+
>
|
|
342
|
+
<td className="px-3 py-2 text-slate-400 border-b border-slate-100 dark:border-slate-800 font-mono">
|
|
343
|
+
{rowIdx + 1}
|
|
344
|
+
</td>
|
|
345
|
+
{columns.map((col, colIdx) => (
|
|
346
|
+
<td
|
|
347
|
+
key={colIdx}
|
|
348
|
+
className="px-3 py-2 text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-slate-800 font-mono"
|
|
349
|
+
>
|
|
350
|
+
{row[col] === null || row[col] === undefined ? (
|
|
351
|
+
<span className="text-slate-400 italic">null</span>
|
|
352
|
+
) : typeof row[col] === 'number' ? (
|
|
353
|
+
row[col].toFixed(4)
|
|
354
|
+
) : (
|
|
355
|
+
String(row[col]).substring(0, 30)
|
|
356
|
+
)}
|
|
357
|
+
</td>
|
|
358
|
+
))}
|
|
359
|
+
</tr>
|
|
360
|
+
))}
|
|
361
|
+
</tbody>
|
|
362
|
+
</table>
|
|
363
|
+
{data.length > maxRows && (
|
|
364
|
+
<div className="px-3 py-2 text-center text-xs text-slate-500 bg-slate-50 dark:bg-slate-900">
|
|
365
|
+
Showing {maxRows} of {data.length} rows
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Helper to safely parse Python-style dict strings
|
|
373
|
+
function parsePythonDict(str) {
|
|
374
|
+
if (!str || typeof str !== 'string') return null;
|
|
375
|
+
try {
|
|
376
|
+
// Replace Python-style quotes and booleans
|
|
377
|
+
let jsonStr = str
|
|
378
|
+
.replace(/'/g, '"') // Replace single quotes with double quotes
|
|
379
|
+
.replace(/True/g, 'true')
|
|
380
|
+
.replace(/False/g, 'false')
|
|
381
|
+
.replace(/None/g, 'null');
|
|
382
|
+
return JSON.parse(jsonStr);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.warn('Failed to parse data string:', e);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Normalize different dataset formats (pandas, tensorflow, numpy, etc.)
|
|
390
|
+
function normalizeDatasetFormat(data) {
|
|
391
|
+
if (!data) return null;
|
|
392
|
+
|
|
393
|
+
// Already in the expected format {features: {...}, target: [...]}
|
|
394
|
+
if (data.features && typeof data.features === 'object') {
|
|
395
|
+
return data;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// TensorFlow dataset metadata format
|
|
399
|
+
// Often stored as: {element_spec: {...}, cardinality: N, ...}
|
|
400
|
+
if (data.element_spec || data.cardinality !== undefined) {
|
|
401
|
+
return {
|
|
402
|
+
_tf_dataset: true,
|
|
403
|
+
element_spec: data.element_spec,
|
|
404
|
+
cardinality: data.cardinality,
|
|
405
|
+
...data
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Pandas DataFrame format: {columns: [...], data: [[...], ...], index: [...]}
|
|
410
|
+
if (data.columns && Array.isArray(data.columns) && data.data && Array.isArray(data.data)) {
|
|
411
|
+
const features = {};
|
|
412
|
+
data.columns.forEach((col, idx) => {
|
|
413
|
+
features[col] = data.data.map(row => row[idx]);
|
|
414
|
+
});
|
|
415
|
+
return { features, target: [] };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Numpy array format: {shape: [...], dtype: '...', data: [...]}
|
|
419
|
+
if (data.shape && data.dtype && Array.isArray(data.data)) {
|
|
420
|
+
// Single feature array
|
|
421
|
+
return {
|
|
422
|
+
features: { values: data.data.flat() },
|
|
423
|
+
target: []
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Dict of arrays format: {col1: [...], col2: [...]}
|
|
428
|
+
if (typeof data === 'object' && !Array.isArray(data)) {
|
|
429
|
+
const keys = Object.keys(data);
|
|
430
|
+
const firstVal = data[keys[0]];
|
|
431
|
+
if (Array.isArray(firstVal)) {
|
|
432
|
+
// Treat last column as target if it's named 'target', 'label', or 'y'
|
|
433
|
+
const targetKeys = ['target', 'label', 'y', 'labels', 'targets'];
|
|
434
|
+
const targetKey = keys.find(k => targetKeys.includes(k.toLowerCase()));
|
|
435
|
+
const featureKeys = keys.filter(k => k !== targetKey);
|
|
436
|
+
|
|
437
|
+
const features = {};
|
|
438
|
+
featureKeys.forEach(k => {
|
|
439
|
+
features[k] = data[k];
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
features,
|
|
444
|
+
target: targetKey ? data[targetKey] : []
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Main DatasetViewer component
|
|
453
|
+
export function DatasetViewer({ artifact }) {
|
|
454
|
+
const [activeTab, setActiveTab] = useState('overview');
|
|
455
|
+
|
|
456
|
+
// Parse dataset information from artifact
|
|
457
|
+
const datasetInfo = useMemo(() => {
|
|
458
|
+
if (!artifact) return null;
|
|
459
|
+
|
|
460
|
+
const props = artifact.properties || {};
|
|
461
|
+
|
|
462
|
+
// Data can be in multiple places:
|
|
463
|
+
// 1. artifact.data (if already parsed by backend)
|
|
464
|
+
// 2. props._full_data (full data stored in properties)
|
|
465
|
+
// 3. artifact.value (string that needs parsing - may be truncated!)
|
|
466
|
+
// 4. props.data
|
|
467
|
+
let rawData = artifact.data || props._full_data || props.data;
|
|
468
|
+
|
|
469
|
+
// If data is not found, try parsing the value field
|
|
470
|
+
if (!rawData && artifact.value) {
|
|
471
|
+
rawData = parsePythonDict(artifact.value);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Normalize the data format (handles pandas, tensorflow, numpy, etc.)
|
|
475
|
+
const data = normalizeDatasetFormat(rawData);
|
|
476
|
+
|
|
477
|
+
// Handle TensorFlow dataset metadata (no actual data, just specs)
|
|
478
|
+
if (data && data._tf_dataset) {
|
|
479
|
+
return {
|
|
480
|
+
name: artifact.name || 'Dataset',
|
|
481
|
+
numSamples: data.cardinality || props.num_samples || props.samples || 'Unknown',
|
|
482
|
+
numFeatures: props.num_features || 0,
|
|
483
|
+
featureColumns: props.feature_columns || [],
|
|
484
|
+
columns: [],
|
|
485
|
+
columnNames: [],
|
|
486
|
+
samples: [],
|
|
487
|
+
source: props.source || 'TensorFlow Dataset',
|
|
488
|
+
createdAt: artifact.created_at,
|
|
489
|
+
isTensorFlow: true,
|
|
490
|
+
elementSpec: data.element_spec,
|
|
491
|
+
tfMetadata: data,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// If still no data, return basic info
|
|
496
|
+
if (!data || typeof data !== 'object') {
|
|
497
|
+
return {
|
|
498
|
+
name: artifact.name || 'Dataset',
|
|
499
|
+
numSamples: props.num_samples || props.samples || props.cardinality || 0,
|
|
500
|
+
numFeatures: props.num_features || (props.feature_columns?.length || 0),
|
|
501
|
+
featureColumns: props.feature_columns || [],
|
|
502
|
+
columns: [],
|
|
503
|
+
columnNames: [],
|
|
504
|
+
samples: [],
|
|
505
|
+
source: props.source,
|
|
506
|
+
createdAt: artifact.created_at,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Try to extract features and target
|
|
511
|
+
let features = data.features || {};
|
|
512
|
+
let target = data.target || [];
|
|
513
|
+
let samples = [];
|
|
514
|
+
let columnNames = [];
|
|
515
|
+
let columns = [];
|
|
516
|
+
|
|
517
|
+
// If features is an object with column arrays
|
|
518
|
+
if (features && typeof features === 'object' && !Array.isArray(features)) {
|
|
519
|
+
columnNames = Object.keys(features);
|
|
520
|
+
const numRows = features[columnNames[0]]?.length || 0;
|
|
521
|
+
|
|
522
|
+
// Build row-based samples for table view
|
|
523
|
+
for (let i = 0; i < numRows; i++) {
|
|
524
|
+
const row = {};
|
|
525
|
+
columnNames.forEach(col => {
|
|
526
|
+
row[col] = features[col]?.[i];
|
|
527
|
+
});
|
|
528
|
+
if (Array.isArray(target) && target.length > i) {
|
|
529
|
+
row['target'] = target[i];
|
|
530
|
+
}
|
|
531
|
+
samples.push(row);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Build column info for statistics
|
|
535
|
+
columnNames.forEach(col => {
|
|
536
|
+
columns.push({
|
|
537
|
+
name: col,
|
|
538
|
+
values: features[col] || [],
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Add target column if exists
|
|
543
|
+
if (Array.isArray(target) && target.length > 0) {
|
|
544
|
+
columnNames.push('target');
|
|
545
|
+
columns.push({
|
|
546
|
+
name: 'target',
|
|
547
|
+
values: target,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
name: artifact.name || 'Dataset',
|
|
554
|
+
numSamples: props.num_samples || props.samples || samples.length,
|
|
555
|
+
numFeatures: props.num_features || columnNames.length - (Array.isArray(target) && target.length > 0 ? 1 : 0),
|
|
556
|
+
featureColumns: props.feature_columns || columnNames.filter(c => c !== 'target'),
|
|
557
|
+
columns,
|
|
558
|
+
columnNames,
|
|
559
|
+
samples,
|
|
560
|
+
source: props.source,
|
|
561
|
+
createdAt: artifact.created_at,
|
|
562
|
+
};
|
|
563
|
+
}, [artifact]);
|
|
564
|
+
|
|
565
|
+
if (!datasetInfo) {
|
|
566
|
+
return (
|
|
567
|
+
<div className="flex flex-col items-center justify-center p-8 text-slate-400">
|
|
568
|
+
<Database size={32} className="mb-2 opacity-50" />
|
|
569
|
+
<p className="text-sm">No dataset information available</p>
|
|
570
|
+
</div>
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Special view for TensorFlow datasets (show metadata/specs instead of data)
|
|
575
|
+
if (datasetInfo.isTensorFlow) {
|
|
576
|
+
return (
|
|
577
|
+
<div className="space-y-6">
|
|
578
|
+
{/* Header */}
|
|
579
|
+
<div className="flex items-center gap-3 pb-4 border-b border-slate-200 dark:border-slate-700">
|
|
580
|
+
<div className="p-3 bg-gradient-to-br from-orange-500 to-red-600 rounded-xl shadow-lg">
|
|
581
|
+
<Database size={24} className="text-white" />
|
|
582
|
+
</div>
|
|
583
|
+
<div>
|
|
584
|
+
<h3 className="text-xl font-bold text-slate-900 dark:text-white">{datasetInfo.name}</h3>
|
|
585
|
+
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
586
|
+
TensorFlow Dataset • {datasetInfo.numSamples} samples
|
|
587
|
+
</p>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
|
|
591
|
+
{/* TF Dataset Info */}
|
|
592
|
+
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-900/20 dark:to-red-900/20 rounded-xl p-6 border border-orange-200 dark:border-orange-800">
|
|
593
|
+
<h4 className="font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
|
|
594
|
+
<Activity size={18} className="text-orange-600" />
|
|
595
|
+
TensorFlow Dataset Metadata
|
|
596
|
+
</h4>
|
|
597
|
+
<div className="grid grid-cols-2 gap-4">
|
|
598
|
+
<div className="bg-white dark:bg-slate-800 p-4 rounded-lg">
|
|
599
|
+
<p className="text-xs text-slate-500 uppercase tracking-wide">Cardinality</p>
|
|
600
|
+
<p className="text-lg font-bold text-slate-900 dark:text-white font-mono">
|
|
601
|
+
{datasetInfo.numSamples}
|
|
602
|
+
</p>
|
|
603
|
+
</div>
|
|
604
|
+
<div className="bg-white dark:bg-slate-800 p-4 rounded-lg">
|
|
605
|
+
<p className="text-xs text-slate-500 uppercase tracking-wide">Source</p>
|
|
606
|
+
<p className="text-lg font-bold text-slate-900 dark:text-white">
|
|
607
|
+
{datasetInfo.source}
|
|
608
|
+
</p>
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
{datasetInfo.elementSpec && (
|
|
612
|
+
<div className="mt-4 bg-white dark:bg-slate-800 p-4 rounded-lg">
|
|
613
|
+
<p className="text-xs text-slate-500 uppercase tracking-wide mb-2">Element Spec</p>
|
|
614
|
+
<pre className="text-xs font-mono text-slate-700 dark:text-slate-300 overflow-x-auto">
|
|
615
|
+
{JSON.stringify(datasetInfo.elementSpec, null, 2)}
|
|
616
|
+
</pre>
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
619
|
+
<p className="text-xs text-slate-500 mt-4 italic">
|
|
620
|
+
💡 TensorFlow datasets are lazy-loaded. Full data visualization requires materializing the dataset.
|
|
621
|
+
</p>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return (
|
|
628
|
+
<div className="space-y-6">
|
|
629
|
+
{/* Header */}
|
|
630
|
+
<div className="flex items-center gap-3 pb-4 border-b border-slate-200 dark:border-slate-700">
|
|
631
|
+
<div className="p-3 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl shadow-lg">
|
|
632
|
+
<Database size={24} className="text-white" />
|
|
633
|
+
</div>
|
|
634
|
+
<div>
|
|
635
|
+
<h3 className="text-xl font-bold text-slate-900 dark:text-white">{datasetInfo.name}</h3>
|
|
636
|
+
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
637
|
+
Dataset with {datasetInfo.numSamples} samples, {datasetInfo.numFeatures} features
|
|
638
|
+
</p>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
{/* Quick Stats */}
|
|
643
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
644
|
+
<StatCard
|
|
645
|
+
icon={Rows}
|
|
646
|
+
label="Samples"
|
|
647
|
+
value={datasetInfo.numSamples?.toLocaleString() || '—'}
|
|
648
|
+
color="blue"
|
|
649
|
+
/>
|
|
650
|
+
<StatCard
|
|
651
|
+
icon={Columns}
|
|
652
|
+
label="Features"
|
|
653
|
+
value={datasetInfo.numFeatures || '—'}
|
|
654
|
+
color="purple"
|
|
655
|
+
/>
|
|
656
|
+
<StatCard
|
|
657
|
+
icon={Hash}
|
|
658
|
+
label="Columns"
|
|
659
|
+
value={datasetInfo.columnNames.length}
|
|
660
|
+
color="emerald"
|
|
661
|
+
/>
|
|
662
|
+
<StatCard
|
|
663
|
+
icon={Info}
|
|
664
|
+
label="Source"
|
|
665
|
+
value={datasetInfo.source || 'Pipeline'}
|
|
666
|
+
color="amber"
|
|
667
|
+
/>
|
|
668
|
+
</div>
|
|
669
|
+
|
|
670
|
+
{/* Tabs */}
|
|
671
|
+
<div className="flex gap-2 border-b border-slate-200 dark:border-slate-700">
|
|
672
|
+
<button
|
|
673
|
+
onClick={() => setActiveTab('overview')}
|
|
674
|
+
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
675
|
+
activeTab === 'overview'
|
|
676
|
+
? 'text-primary-600 border-b-2 border-primary-600'
|
|
677
|
+
: 'text-slate-500 hover:text-slate-700'
|
|
678
|
+
}`}
|
|
679
|
+
>
|
|
680
|
+
<BarChart3 size={14} className="inline mr-1" />
|
|
681
|
+
Column Statistics
|
|
682
|
+
</button>
|
|
683
|
+
<button
|
|
684
|
+
onClick={() => setActiveTab('sample')}
|
|
685
|
+
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
686
|
+
activeTab === 'sample'
|
|
687
|
+
? 'text-primary-600 border-b-2 border-primary-600'
|
|
688
|
+
: 'text-slate-500 hover:text-slate-700'
|
|
689
|
+
}`}
|
|
690
|
+
>
|
|
691
|
+
<Table size={14} className="inline mr-1" />
|
|
692
|
+
Sample Data
|
|
693
|
+
</button>
|
|
694
|
+
</div>
|
|
695
|
+
|
|
696
|
+
{/* Tab Content */}
|
|
697
|
+
<AnimatePresence mode="wait">
|
|
698
|
+
{activeTab === 'overview' && (
|
|
699
|
+
<motion.div
|
|
700
|
+
key="overview"
|
|
701
|
+
initial={{ opacity: 0, y: 10 }}
|
|
702
|
+
animate={{ opacity: 1, y: 0 }}
|
|
703
|
+
exit={{ opacity: 0, y: -10 }}
|
|
704
|
+
className="space-y-3"
|
|
705
|
+
>
|
|
706
|
+
{datasetInfo.columns.length > 0 ? (
|
|
707
|
+
datasetInfo.columns.map((column, idx) => (
|
|
708
|
+
<ColumnCard
|
|
709
|
+
key={column.name}
|
|
710
|
+
column={column}
|
|
711
|
+
index={idx}
|
|
712
|
+
data={datasetInfo.samples}
|
|
713
|
+
/>
|
|
714
|
+
))
|
|
715
|
+
) : (
|
|
716
|
+
<div className="text-center py-8 text-slate-400">
|
|
717
|
+
<BarChart3 size={32} className="mx-auto mb-2 opacity-50" />
|
|
718
|
+
<p className="text-sm">Column statistics not available</p>
|
|
719
|
+
<p className="text-xs text-slate-500 mt-1">
|
|
720
|
+
Data structure may not support detailed analysis
|
|
721
|
+
</p>
|
|
722
|
+
</div>
|
|
723
|
+
)}
|
|
724
|
+
</motion.div>
|
|
725
|
+
)}
|
|
726
|
+
|
|
727
|
+
{activeTab === 'sample' && (
|
|
728
|
+
<motion.div
|
|
729
|
+
key="sample"
|
|
730
|
+
initial={{ opacity: 0, y: 10 }}
|
|
731
|
+
animate={{ opacity: 1, y: 0 }}
|
|
732
|
+
exit={{ opacity: 0, y: -10 }}
|
|
733
|
+
>
|
|
734
|
+
{datasetInfo.samples.length > 0 ? (
|
|
735
|
+
<SampleDataTable
|
|
736
|
+
data={datasetInfo.samples}
|
|
737
|
+
columns={datasetInfo.columnNames}
|
|
738
|
+
maxRows={15}
|
|
739
|
+
/>
|
|
740
|
+
) : (
|
|
741
|
+
<div className="text-center py-8 text-slate-400">
|
|
742
|
+
<Table size={32} className="mx-auto mb-2 opacity-50" />
|
|
743
|
+
<p className="text-sm">Sample data not available</p>
|
|
744
|
+
</div>
|
|
745
|
+
)}
|
|
746
|
+
</motion.div>
|
|
747
|
+
)}
|
|
748
|
+
</AnimatePresence>
|
|
749
|
+
</div>
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export default DatasetViewer;
|