flowyml 1.2.0__py3-none-any.whl → 1.4.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/__init__.py +3 -0
- flowyml/assets/base.py +10 -0
- flowyml/assets/metrics.py +6 -0
- flowyml/cli/main.py +108 -2
- flowyml/cli/run.py +9 -2
- flowyml/core/execution_status.py +52 -0
- flowyml/core/hooks.py +106 -0
- flowyml/core/observability.py +210 -0
- flowyml/core/orchestrator.py +274 -0
- flowyml/core/pipeline.py +193 -231
- flowyml/core/project.py +34 -2
- flowyml/core/remote_orchestrator.py +109 -0
- flowyml/core/resources.py +34 -17
- flowyml/core/retry_policy.py +80 -0
- flowyml/core/scheduler.py +9 -9
- flowyml/core/scheduler_config.py +2 -3
- flowyml/core/step.py +18 -1
- flowyml/core/submission_result.py +53 -0
- flowyml/integrations/keras.py +95 -22
- flowyml/monitoring/alerts.py +2 -2
- flowyml/stacks/__init__.py +15 -0
- flowyml/stacks/aws.py +599 -0
- flowyml/stacks/azure.py +295 -0
- flowyml/stacks/bridge.py +9 -9
- flowyml/stacks/components.py +24 -2
- flowyml/stacks/gcp.py +158 -11
- flowyml/stacks/local.py +5 -0
- flowyml/stacks/plugins.py +2 -2
- flowyml/stacks/registry.py +21 -0
- flowyml/storage/artifacts.py +15 -5
- flowyml/storage/materializers/__init__.py +2 -0
- flowyml/storage/materializers/base.py +33 -0
- flowyml/storage/materializers/cloudpickle.py +74 -0
- flowyml/storage/metadata.py +3 -881
- flowyml/storage/remote.py +590 -0
- flowyml/storage/sql.py +911 -0
- flowyml/ui/backend/dependencies.py +28 -0
- flowyml/ui/backend/main.py +43 -80
- flowyml/ui/backend/routers/assets.py +483 -17
- flowyml/ui/backend/routers/client.py +46 -0
- flowyml/ui/backend/routers/execution.py +13 -2
- flowyml/ui/backend/routers/experiments.py +97 -14
- flowyml/ui/backend/routers/metrics.py +168 -0
- flowyml/ui/backend/routers/pipelines.py +77 -12
- flowyml/ui/backend/routers/projects.py +33 -7
- flowyml/ui/backend/routers/runs.py +221 -12
- flowyml/ui/backend/routers/schedules.py +5 -21
- flowyml/ui/backend/routers/stats.py +14 -0
- flowyml/ui/backend/routers/traces.py +37 -53
- flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
- flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/src/App.jsx +4 -1
- flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
- flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
- flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
- flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
- flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
- flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
- flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
- flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
- flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
- flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
- flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
- flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
- flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
- flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
- flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
- flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
- flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
- flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
- flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
- flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
- flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
- flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
- flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
- flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
- flowyml/ui/frontend/src/router/index.jsx +4 -0
- flowyml/ui/frontend/src/utils/date.js +10 -0
- flowyml/ui/frontend/src/utils/downloads.js +11 -0
- flowyml/utils/config.py +6 -0
- flowyml/utils/stack_config.py +45 -3
- {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/METADATA +44 -4
- flowyml-1.4.0.dist-info/RECORD +200 -0
- {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/licenses/LICENSE +1 -1
- flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
- flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
- flowyml-1.2.0.dist-info/RECORD +0 -159
- {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/WHEEL +0 -0
- {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { fetchApi } from '../utils/api';
|
|
3
|
+
import { Database, Box, BarChart2, FileText, TrendingUp, HardDrive, Activity, Package, ChevronDown } from 'lucide-react';
|
|
4
|
+
import { motion } from 'framer-motion';
|
|
5
|
+
import { Card } from './ui/Card';
|
|
6
|
+
|
|
7
|
+
const AnimatedCounter = ({ value, duration = 1000 }) => {
|
|
8
|
+
const [count, setCount] = useState(0);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let startTime;
|
|
12
|
+
let animationFrame;
|
|
13
|
+
|
|
14
|
+
const animate = (timestamp) => {
|
|
15
|
+
if (!startTime) startTime = timestamp;
|
|
16
|
+
const progress = Math.min((timestamp - startTime) / duration, 1);
|
|
17
|
+
|
|
18
|
+
setCount(Math.floor(progress * value));
|
|
19
|
+
|
|
20
|
+
if (progress < 1) {
|
|
21
|
+
animationFrame = requestAnimationFrame(animate);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
animationFrame = requestAnimationFrame(animate);
|
|
26
|
+
return () => cancelAnimationFrame(animationFrame);
|
|
27
|
+
}, [value, duration]);
|
|
28
|
+
|
|
29
|
+
return <span>{count.toLocaleString()}</span>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const StatCard = ({ icon: Icon, label, value, subtitle, gradient, isLoading }) => {
|
|
33
|
+
if (isLoading) {
|
|
34
|
+
return (
|
|
35
|
+
<Card className="p-6 animate-pulse">
|
|
36
|
+
<div className="h-20 bg-slate-200 dark:bg-slate-700 rounded"></div>
|
|
37
|
+
</Card>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<motion.div
|
|
43
|
+
initial={{ opacity: 0, y: 20 }}
|
|
44
|
+
animate={{ opacity: 1, y: 0 }}
|
|
45
|
+
transition={{ duration: 0.3 }}
|
|
46
|
+
>
|
|
47
|
+
<Card className="p-6 hover:shadow-xl transition-all duration-300 border-2 hover:border-primary-300 dark:hover:border-primary-700 group overflow-hidden relative">
|
|
48
|
+
{/* Gradient background effect */}
|
|
49
|
+
<div className={`absolute inset-0 bg-gradient-to-br ${gradient} opacity-0 group-hover:opacity-5 transition-opacity duration-300`}></div>
|
|
50
|
+
|
|
51
|
+
<div className="relative">
|
|
52
|
+
<div className="flex items-start justify-between mb-3">
|
|
53
|
+
<div className={`p-3 rounded-xl bg-gradient-to-br ${gradient} text-white shadow-lg group-hover:scale-110 transition-transform duration-300`}>
|
|
54
|
+
<Icon size={24} />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div className="space-y-1">
|
|
59
|
+
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">{label}</p>
|
|
60
|
+
<p className="text-3xl font-bold text-slate-900 dark:text-white">
|
|
61
|
+
{typeof value === 'number' ? <AnimatedCounter value={value} /> : value}
|
|
62
|
+
</p>
|
|
63
|
+
{subtitle && (
|
|
64
|
+
<p className="text-xs text-slate-400 dark:text-slate-500">{subtitle}</p>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</Card>
|
|
69
|
+
</motion.div>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const TypeBreakdownChart = ({ typeData }) => {
|
|
74
|
+
const typeConfig = {
|
|
75
|
+
model: { icon: Box, color: 'from-purple-500 to-pink-500', text: 'text-purple-600 dark:text-purple-400' },
|
|
76
|
+
dataset: { icon: Database, color: 'from-blue-500 to-cyan-500', text: 'text-blue-600 dark:text-blue-400' },
|
|
77
|
+
metrics: { icon: BarChart2, color: 'from-emerald-500 to-teal-500', text: 'text-emerald-600 dark:text-emerald-400' },
|
|
78
|
+
default: { icon: FileText, color: 'from-slate-500 to-slate-600', text: 'text-slate-600 dark:text-slate-400' }
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const total = Object.values(typeData).reduce((sum, count) => sum + count, 0);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="space-y-3">
|
|
85
|
+
{Object.entries(typeData).map(([type, count]) => {
|
|
86
|
+
const config = typeConfig[type.toLowerCase()] || typeConfig.default;
|
|
87
|
+
const Icon = config.icon;
|
|
88
|
+
const percentage = total > 0 ? (count / total) * 100 : 0;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<motion.div
|
|
92
|
+
key={type}
|
|
93
|
+
initial={{ opacity: 0, x: -20 }}
|
|
94
|
+
animate={{ opacity: 1, x: 0 }}
|
|
95
|
+
className="group"
|
|
96
|
+
>
|
|
97
|
+
<div className="flex items-center justify-between mb-1">
|
|
98
|
+
<div className="flex items-center gap-2">
|
|
99
|
+
<div className={`p-1.5 rounded-lg bg-gradient-to-br ${config.color} text-white`}>
|
|
100
|
+
<Icon size={14} />
|
|
101
|
+
</div>
|
|
102
|
+
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">{type}</span>
|
|
103
|
+
</div>
|
|
104
|
+
<span className="text-sm font-semibold text-slate-900 dark:text-white">{count}</span>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
107
|
+
<motion.div
|
|
108
|
+
initial={{ width: 0 }}
|
|
109
|
+
animate={{ width: `${percentage}%` }}
|
|
110
|
+
transition={{ duration: 0.8, ease: "easeOut" }}
|
|
111
|
+
className={`h-full bg-gradient-to-r ${config.color} group-hover:shadow-lg transition-shadow`}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
</motion.div>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function AssetStatsDashboard({ projectId }) {
|
|
122
|
+
const [stats, setStats] = useState(null);
|
|
123
|
+
const [loading, setLoading] = useState(true);
|
|
124
|
+
const [showDistribution, setShowDistribution] = useState(false);
|
|
125
|
+
const [showRecent, setShowRecent] = useState(false);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const fetchStats = async () => {
|
|
129
|
+
setLoading(true);
|
|
130
|
+
try {
|
|
131
|
+
const url = projectId
|
|
132
|
+
? `/api/assets/stats?project=${encodeURIComponent(projectId)}`
|
|
133
|
+
: '/api/assets/stats';
|
|
134
|
+
const res = await fetchApi(url);
|
|
135
|
+
const data = await res.json();
|
|
136
|
+
setStats(data);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error('Failed to fetch asset stats:', err);
|
|
139
|
+
} finally {
|
|
140
|
+
setLoading(false);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
fetchStats();
|
|
145
|
+
}, [projectId]);
|
|
146
|
+
|
|
147
|
+
const formatBytes = (bytes) => {
|
|
148
|
+
if (bytes === 0) return '0 Bytes';
|
|
149
|
+
const k = 1024;
|
|
150
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
151
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
152
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className="space-y-6">
|
|
157
|
+
{/* Main Stats Grid */}
|
|
158
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
159
|
+
<StatCard
|
|
160
|
+
icon={Package}
|
|
161
|
+
label="Total Assets"
|
|
162
|
+
value={stats?.total_assets || 0}
|
|
163
|
+
subtitle="Across all pipelines"
|
|
164
|
+
gradient="from-blue-500 to-cyan-500"
|
|
165
|
+
isLoading={loading}
|
|
166
|
+
/>
|
|
167
|
+
<StatCard
|
|
168
|
+
icon={Box}
|
|
169
|
+
label="Models"
|
|
170
|
+
value={stats?.by_type?.model || stats?.by_type?.Model || 0}
|
|
171
|
+
subtitle="Trained models"
|
|
172
|
+
gradient="from-purple-500 to-pink-500"
|
|
173
|
+
isLoading={loading}
|
|
174
|
+
/>
|
|
175
|
+
<StatCard
|
|
176
|
+
icon={Database}
|
|
177
|
+
label="Datasets"
|
|
178
|
+
value={stats?.by_type?.dataset || stats?.by_type?.Dataset || 0}
|
|
179
|
+
subtitle="Data artifacts"
|
|
180
|
+
gradient="from-emerald-500 to-teal-500"
|
|
181
|
+
isLoading={loading}
|
|
182
|
+
/>
|
|
183
|
+
<StatCard
|
|
184
|
+
icon={HardDrive}
|
|
185
|
+
label="Storage Used"
|
|
186
|
+
value={stats ? formatBytes(stats.total_storage_bytes) : '0 MB'}
|
|
187
|
+
subtitle="Total size"
|
|
188
|
+
gradient="from-orange-500 to-red-500"
|
|
189
|
+
isLoading={loading}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Type Breakdown - Collapsible */}
|
|
194
|
+
{stats && Object.keys(stats.by_type).length > 0 && (
|
|
195
|
+
<motion.div
|
|
196
|
+
initial={{ opacity: 0, y: 20 }}
|
|
197
|
+
animate={{ opacity: 1, y: 0 }}
|
|
198
|
+
transition={{ delay: 0.2 }}
|
|
199
|
+
>
|
|
200
|
+
<Card className="overflow-hidden">
|
|
201
|
+
<div
|
|
202
|
+
className="flex items-center justify-between p-4 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
|
203
|
+
onClick={() => setShowDistribution(!showDistribution)}
|
|
204
|
+
>
|
|
205
|
+
<div className="flex items-center gap-2">
|
|
206
|
+
<TrendingUp className="text-primary-600" size={20} />
|
|
207
|
+
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Asset Distribution</h3>
|
|
208
|
+
</div>
|
|
209
|
+
<motion.div
|
|
210
|
+
animate={{ rotate: showDistribution ? 180 : 0 }}
|
|
211
|
+
transition={{ duration: 0.2 }}
|
|
212
|
+
>
|
|
213
|
+
<ChevronDown className="text-slate-400" size={20} />
|
|
214
|
+
</motion.div>
|
|
215
|
+
</div>
|
|
216
|
+
{showDistribution && (
|
|
217
|
+
<motion.div
|
|
218
|
+
initial={{ opacity: 0, height: 0 }}
|
|
219
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
220
|
+
exit={{ opacity: 0, height: 0 }}
|
|
221
|
+
transition={{ duration: 0.2 }}
|
|
222
|
+
className="px-6 pb-6"
|
|
223
|
+
>
|
|
224
|
+
<TypeBreakdownChart typeData={stats.by_type} />
|
|
225
|
+
</motion.div>
|
|
226
|
+
)}
|
|
227
|
+
</Card>
|
|
228
|
+
</motion.div>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
{/* Recent Activity - Collapsible */}
|
|
232
|
+
{stats && stats.recent_assets && stats.recent_assets.length > 0 && (
|
|
233
|
+
<motion.div
|
|
234
|
+
initial={{ opacity: 0, y: 20 }}
|
|
235
|
+
animate={{ opacity: 1, y: 0 }}
|
|
236
|
+
transition={{ delay: 0.3 }}
|
|
237
|
+
>
|
|
238
|
+
<Card className="overflow-hidden">
|
|
239
|
+
<div
|
|
240
|
+
className="flex items-center justify-between p-4 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
|
241
|
+
onClick={() => setShowRecent(!showRecent)}
|
|
242
|
+
>
|
|
243
|
+
<div className="flex items-center gap-2">
|
|
244
|
+
<Activity className="text-primary-600" size={20} />
|
|
245
|
+
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Recent Assets</h3>
|
|
246
|
+
</div>
|
|
247
|
+
<motion.div
|
|
248
|
+
animate={{ rotate: showRecent ? 180 : 0 }}
|
|
249
|
+
transition={{ duration: 0.2 }}
|
|
250
|
+
>
|
|
251
|
+
<ChevronDown className="text-slate-400" size={20} />
|
|
252
|
+
</motion.div>
|
|
253
|
+
</div>
|
|
254
|
+
{showRecent && (
|
|
255
|
+
<motion.div
|
|
256
|
+
initial={{ opacity: 0, height: 0 }}
|
|
257
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
258
|
+
exit={{ opacity: 0, height: 0 }}
|
|
259
|
+
transition={{ duration: 0.2 }}
|
|
260
|
+
className="px-6 pb-6"
|
|
261
|
+
>
|
|
262
|
+
<div className="space-y-2">
|
|
263
|
+
{stats.recent_assets.slice(0, 5).map((asset, idx) => {
|
|
264
|
+
const typeConfig = {
|
|
265
|
+
model: { icon: Box, color: 'text-purple-600' },
|
|
266
|
+
Model: { icon: Box, color: 'text-purple-600' },
|
|
267
|
+
dataset: { icon: Database, color: 'text-blue-600' },
|
|
268
|
+
Dataset: { icon: Database, color: 'text-blue-600' },
|
|
269
|
+
default: { icon: FileText, color: 'text-slate-600' }
|
|
270
|
+
};
|
|
271
|
+
const config = typeConfig[asset.type] || typeConfig.default;
|
|
272
|
+
const Icon = config.icon;
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<motion.div
|
|
276
|
+
key={asset.artifact_id || idx}
|
|
277
|
+
initial={{ opacity: 0, x: -20 }}
|
|
278
|
+
animate={{ opacity: 1, x: 0 }}
|
|
279
|
+
transition={{ delay: idx * 0.05 }}
|
|
280
|
+
className="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
|
281
|
+
>
|
|
282
|
+
<Icon className={config.color} size={16} />
|
|
283
|
+
<div className="flex-1 min-w-0">
|
|
284
|
+
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
|
285
|
+
{asset.name}
|
|
286
|
+
</p>
|
|
287
|
+
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
288
|
+
{asset.type} • {asset.step || 'N/A'}
|
|
289
|
+
</p>
|
|
290
|
+
</div>
|
|
291
|
+
</motion.div>
|
|
292
|
+
);
|
|
293
|
+
})}
|
|
294
|
+
</div>
|
|
295
|
+
</motion.div>
|
|
296
|
+
)}
|
|
297
|
+
</Card>
|
|
298
|
+
</motion.div>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|