flowyml 1.7.2__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/metrics.py +5 -0
- flowyml/cli/main.py +709 -0
- flowyml/cli/stack_cli.py +138 -25
- flowyml/core/__init__.py +17 -0
- flowyml/core/executor.py +161 -26
- flowyml/core/image_builder.py +129 -0
- flowyml/core/log_streamer.py +227 -0
- flowyml/core/orchestrator.py +22 -2
- flowyml/core/pipeline.py +34 -10
- flowyml/core/routing.py +558 -0
- flowyml/core/step.py +9 -1
- flowyml/core/step_grouping.py +49 -35
- flowyml/core/types.py +407 -0
- 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 +20 -1
- flowyml/ui/backend/routers/schedules.py +22 -17
- 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 +1404 -74
- flowyml/ui/frontend/package.json +3 -0
- flowyml/ui/frontend/public/logo.png +0 -0
- flowyml/ui/frontend/src/App.jsx +10 -7
- 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/runs/[runId]/page.jsx +36 -24
- 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/AssetDetailsPanel.jsx +29 -7
- 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/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.2.dist-info → flowyml-1.8.0.dist-info}/RECORD +123 -65
- {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
- flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +0 -1
- flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +0 -685
- flowyml-1.7.2.dist-info/METADATA +0 -477
- {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
- {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Microscope,
|
|
4
|
+
Play,
|
|
5
|
+
RefreshCw,
|
|
6
|
+
Sliders,
|
|
7
|
+
BarChart3,
|
|
8
|
+
Loader2,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
Zap,
|
|
11
|
+
LineChart,
|
|
12
|
+
Settings,
|
|
13
|
+
Download,
|
|
14
|
+
History,
|
|
15
|
+
Trash2,
|
|
16
|
+
Plus,
|
|
17
|
+
Minus,
|
|
18
|
+
Globe,
|
|
19
|
+
Link,
|
|
20
|
+
Unlink,
|
|
21
|
+
Key,
|
|
22
|
+
Code,
|
|
23
|
+
FileJson,
|
|
24
|
+
Table,
|
|
25
|
+
Copy,
|
|
26
|
+
Check,
|
|
27
|
+
AlertCircle,
|
|
28
|
+
GitCompare,
|
|
29
|
+
ArrowLeftRight,
|
|
30
|
+
SplitSquareHorizontal,
|
|
31
|
+
TrendingUp,
|
|
32
|
+
Activity,
|
|
33
|
+
Sparkles,
|
|
34
|
+
Target,
|
|
35
|
+
Layers,
|
|
36
|
+
PanelLeftClose,
|
|
37
|
+
PanelLeftOpen,
|
|
38
|
+
Terminal,
|
|
39
|
+
Info,
|
|
40
|
+
X
|
|
41
|
+
} from 'lucide-react';
|
|
42
|
+
import { Card } from '../../components/ui/Card';
|
|
43
|
+
import { Button } from '../../components/ui/Button';
|
|
44
|
+
import { Badge } from '../../components/ui/Badge';
|
|
45
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
46
|
+
|
|
47
|
+
// Helper to safely display values
|
|
48
|
+
const formatValue = (val) => {
|
|
49
|
+
if (val === null || val === undefined) return 'N/A';
|
|
50
|
+
if (typeof val === 'number') return val.toFixed(4);
|
|
51
|
+
if (typeof val === 'object') return JSON.stringify(val, null, 2);
|
|
52
|
+
return String(val);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Simple JSON Editor component
|
|
56
|
+
const JsonEditor = ({ value, onChange, error }) => {
|
|
57
|
+
const [text, setText] = useState('');
|
|
58
|
+
const [parseError, setParseError] = useState(null);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
setText(JSON.stringify(value, null, 2));
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const handleChange = (e) => {
|
|
65
|
+
const newText = e.target.value;
|
|
66
|
+
setText(newText);
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(newText);
|
|
69
|
+
setParseError(null);
|
|
70
|
+
onChange(parsed);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
setParseError(err.message);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="relative">
|
|
78
|
+
<textarea
|
|
79
|
+
value={text}
|
|
80
|
+
onChange={handleChange}
|
|
81
|
+
className={`w-full h-48 p-3 font-mono text-sm rounded-lg border-2 transition-all
|
|
82
|
+
${parseError ? 'border-rose-400 bg-rose-50 dark:bg-rose-900/10' : 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900'}
|
|
83
|
+
focus:outline-none focus:ring-2 focus:ring-violet-500`}
|
|
84
|
+
placeholder="Enter JSON input..."
|
|
85
|
+
/>
|
|
86
|
+
{parseError && (
|
|
87
|
+
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-rose-500 text-white text-xs rounded-b-lg">
|
|
88
|
+
<AlertCircle size={12} className="inline mr-1" />
|
|
89
|
+
{parseError}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Comparison Result Card
|
|
97
|
+
const ComparisonResult = ({ results, models }) => {
|
|
98
|
+
if (!results || results.length < 2) return null;
|
|
99
|
+
|
|
100
|
+
const metrics = Object.keys(results[0]?.outputs || {});
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="space-y-4">
|
|
104
|
+
<h4 className="font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
|
105
|
+
<GitCompare size={16} className="text-violet-500" />
|
|
106
|
+
Comparison Results
|
|
107
|
+
</h4>
|
|
108
|
+
<div className="overflow-x-auto">
|
|
109
|
+
<table className="w-full text-sm">
|
|
110
|
+
<thead>
|
|
111
|
+
<tr className="bg-slate-100 dark:bg-slate-800">
|
|
112
|
+
<th className="px-4 py-2 text-left font-medium text-slate-600">Metric</th>
|
|
113
|
+
{results.map((r, i) => (
|
|
114
|
+
<th key={i} className="px-4 py-2 text-center font-medium text-slate-600">
|
|
115
|
+
{models[i]?.name || `Model ${i + 1}`}
|
|
116
|
+
</th>
|
|
117
|
+
))}
|
|
118
|
+
<th className="px-4 py-2 text-center font-medium text-slate-600">Δ Diff</th>
|
|
119
|
+
</tr>
|
|
120
|
+
</thead>
|
|
121
|
+
<tbody>
|
|
122
|
+
{metrics.map(metric => (
|
|
123
|
+
<tr key={metric} className="border-b border-slate-200 dark:border-slate-700">
|
|
124
|
+
<td className="px-4 py-2 font-medium text-slate-700 dark:text-slate-300">{metric}</td>
|
|
125
|
+
{results.map((r, i) => (
|
|
126
|
+
<td key={i} className="px-4 py-2 text-center text-violet-600 dark:text-violet-400 font-mono">
|
|
127
|
+
{formatValue(r.outputs?.[metric])}
|
|
128
|
+
</td>
|
|
129
|
+
))}
|
|
130
|
+
<td className="px-4 py-2 text-center font-mono">
|
|
131
|
+
{typeof results[0]?.outputs?.[metric] === 'number' && typeof results[1]?.outputs?.[metric] === 'number' ? (
|
|
132
|
+
<span className={results[0].outputs[metric] > results[1].outputs[metric] ? 'text-emerald-500' : 'text-rose-500'}>
|
|
133
|
+
{((results[0].outputs[metric] - results[1].outputs[metric]) * 100 / Math.max(Math.abs(results[1].outputs[metric]), 0.0001)).toFixed(2)}%
|
|
134
|
+
</span>
|
|
135
|
+
) : '-'}
|
|
136
|
+
</td>
|
|
137
|
+
</tr>
|
|
138
|
+
))}
|
|
139
|
+
<tr className="bg-slate-50 dark:bg-slate-800/50">
|
|
140
|
+
<td className="px-4 py-2 font-medium text-slate-600">Latency</td>
|
|
141
|
+
{results.map((r, i) => (
|
|
142
|
+
<td key={i} className="px-4 py-2 text-center font-mono text-slate-500">
|
|
143
|
+
{r.latency_ms?.toFixed(2)}ms
|
|
144
|
+
</td>
|
|
145
|
+
))}
|
|
146
|
+
<td className="px-4 py-2 text-center font-mono">
|
|
147
|
+
{results[0]?.latency_ms && results[1]?.latency_ms ? (
|
|
148
|
+
<span className={results[0].latency_ms < results[1].latency_ms ? 'text-emerald-500' : 'text-rose-500'}>
|
|
149
|
+
{((results[0].latency_ms - results[1].latency_ms) / results[1].latency_ms * 100).toFixed(1)}%
|
|
150
|
+
</span>
|
|
151
|
+
) : '-'}
|
|
152
|
+
</td>
|
|
153
|
+
</tr>
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Enhanced Chart Component
|
|
162
|
+
const PredictionChart = ({ data, title }) => {
|
|
163
|
+
if (!data || data.length === 0) return null;
|
|
164
|
+
|
|
165
|
+
const maxVal = Math.max(...data.map(d => Math.abs(d.value || d.prediction || 0)));
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="p-6 bg-white dark:bg-slate-800/50 rounded-2xl border border-slate-200 dark:border-slate-700/50 shadow-lg shadow-violet-500/5 backdrop-blur-sm">
|
|
169
|
+
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-6 flex items-center gap-2">
|
|
170
|
+
<BarChart3 size={14} />
|
|
171
|
+
{title}
|
|
172
|
+
</h4>
|
|
173
|
+
<div className="flex items-end gap-2 h-40">
|
|
174
|
+
{data.map((d, i) => {
|
|
175
|
+
const val = d.value || d.prediction || 0;
|
|
176
|
+
const height = maxVal > 0 ? (Math.abs(val) / maxVal) * 100 : 50;
|
|
177
|
+
const isPositive = val >= 0;
|
|
178
|
+
return (
|
|
179
|
+
<div key={i} className="flex-1 flex flex-col items-center group relative h-full justify-end">
|
|
180
|
+
<motion.div
|
|
181
|
+
initial={{ height: 0, opacity: 0 }}
|
|
182
|
+
animate={{ height: `${Math.max(height, 5)}%`, opacity: 1 }}
|
|
183
|
+
transition={{ duration: 0.6, delay: i * 0.05, ease: "backOut" }}
|
|
184
|
+
className={`w-full rounded-t-lg transition-all cursor-pointer relative overflow-hidden
|
|
185
|
+
${isPositive
|
|
186
|
+
? 'bg-gradient-to-t from-violet-600 to-indigo-400 group-hover:from-violet-500 group-hover:to-indigo-300'
|
|
187
|
+
: 'bg-gradient-to-t from-rose-600 to-pink-400 group-hover:from-rose-500 group-hover:to-pink-300'
|
|
188
|
+
}`}
|
|
189
|
+
>
|
|
190
|
+
<div className="absolute inset-0 bg-white/20 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
191
|
+
</motion.div>
|
|
192
|
+
|
|
193
|
+
{/* Tooltip */}
|
|
194
|
+
<div className="absolute -top-10 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-all duration-200 bg-slate-900/90 text-white text-[10px] font-mono px-2 py-1 rounded-md whitespace-nowrap z-20 pointer-events-none shadow-xl -translate-y-2 group-hover:translate-y-0">
|
|
195
|
+
{d.label}: <span className={isPositive ? "text-emerald-400" : "text-rose-400"}>{formatValue(val)}</span>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
})}
|
|
200
|
+
</div>
|
|
201
|
+
<div className="flex justify-between mt-3 px-1">
|
|
202
|
+
<span className="text-[10px] font-medium text-slate-400">{data[0]?.label || '0'}</span>
|
|
203
|
+
<span className="text-[10px] font-medium text-slate-400">{data[data.length - 1]?.label || data.length - 1}</span>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Logs Panel Component for live deployment logs
|
|
210
|
+
const LogsPanel = ({ logs, onClose, deploymentId }) => {
|
|
211
|
+
const logsEndRef = React.useRef(null);
|
|
212
|
+
|
|
213
|
+
React.useEffect(() => {
|
|
214
|
+
if (logsEndRef.current) {
|
|
215
|
+
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
216
|
+
}
|
|
217
|
+
}, [logs]);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<motion.div
|
|
221
|
+
initial={{ opacity: 0 }}
|
|
222
|
+
animate={{ opacity: 1 }}
|
|
223
|
+
className="bg-[#0D1117] rounded-xl border border-slate-800 overflow-hidden shadow-2xl flex flex-col h-full"
|
|
224
|
+
>
|
|
225
|
+
<div className="flex items-center justify-between px-4 py-2 bg-[#161B22] border-b border-slate-800">
|
|
226
|
+
<div className="flex items-center gap-2">
|
|
227
|
+
<Terminal size={14} className="text-emerald-500" />
|
|
228
|
+
<span className="text-xs font-mono text-slate-400">root@deployment:~/{deploymentId}</span>
|
|
229
|
+
</div>
|
|
230
|
+
<div className="flex gap-1.5">
|
|
231
|
+
<div className="w-2.5 h-2.5 rounded-full bg-slate-700"></div>
|
|
232
|
+
<div className="w-2.5 h-2.5 rounded-full bg-slate-700"></div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div className="flex-1 overflow-y-auto p-4 font-mono text-[11px] leading-relaxed space-y-0.5 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent">
|
|
236
|
+
{logs.length === 0 ? (
|
|
237
|
+
<div className="text-slate-600 italic text-center mt-10">Waiting for logs stream...</div>
|
|
238
|
+
) : (
|
|
239
|
+
logs.map((log, i) => (
|
|
240
|
+
<div key={i} className="flex gap-3 hover:bg-white/5 px-2 -mx-2 rounded transition-colors group">
|
|
241
|
+
<span className="text-slate-600 shrink-0 select-none w-16 text-right">
|
|
242
|
+
{new Date(log.timestamp).toLocaleTimeString([], { hour12: false })}
|
|
243
|
+
</span>
|
|
244
|
+
<span className={`font-bold shrink-0 w-12 ${log.level === 'ERROR' ? 'text-rose-500' :
|
|
245
|
+
log.level === 'WARNING' ? 'text-amber-500' :
|
|
246
|
+
log.level === 'INFO' ? 'text-emerald-500' : 'text-blue-400'
|
|
247
|
+
}`}>
|
|
248
|
+
{log.level}
|
|
249
|
+
</span>
|
|
250
|
+
<span className="text-slate-300 group-hover:text-white transition-colors">{log.message}</span>
|
|
251
|
+
</div>
|
|
252
|
+
))
|
|
253
|
+
)}
|
|
254
|
+
<div ref={logsEndRef} />
|
|
255
|
+
</div>
|
|
256
|
+
</motion.div>
|
|
257
|
+
);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Model Info Panel Component
|
|
261
|
+
const ModelInfoPanel = ({ modelInfo }) => {
|
|
262
|
+
if (!modelInfo) return null;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<motion.div
|
|
266
|
+
initial={{ opacity: 0 }}
|
|
267
|
+
animate={{ opacity: 1 }}
|
|
268
|
+
className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700/50 shadow-sm overflow-hidden"
|
|
269
|
+
>
|
|
270
|
+
<div className="border-b border-slate-100 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 p-4">
|
|
271
|
+
<div className="flex items-center gap-2">
|
|
272
|
+
<div className="p-1.5 bg-violet-100 dark:bg-violet-900/30 rounded-lg">
|
|
273
|
+
<Info size={16} className="text-violet-600 dark:text-violet-400" />
|
|
274
|
+
</div>
|
|
275
|
+
<div>
|
|
276
|
+
<h4 className="text-sm font-bold text-slate-900 dark:text-white">Model Specifications</h4>
|
|
277
|
+
<p className="text-[10px] text-slate-500">Technical details and architecture</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
283
|
+
<div className="space-y-1">
|
|
284
|
+
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Framework</label>
|
|
285
|
+
<div className="flex items-center gap-2">
|
|
286
|
+
<div className="font-semibold text-slate-800 dark:text-slate-200">{modelInfo.framework || 'Unknown'}</div>
|
|
287
|
+
<Badge variant="secondary" className="text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-500">{modelInfo.framework_version || 'v1.0'}</Badge>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{modelInfo.input_shape && (
|
|
292
|
+
<div className="space-y-1">
|
|
293
|
+
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Input Shape</label>
|
|
294
|
+
<div className="font-mono text-xs font-medium text-violet-600 dark:text-violet-300 bg-violet-50 dark:bg-violet-900/20 px-2 py-1 rounded-md inline-block border border-violet-100 dark:border-violet-800/30">
|
|
295
|
+
[{modelInfo.input_shape.map(s => s || '?').join(', ')}]
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{modelInfo.output_shape && (
|
|
301
|
+
<div className="space-y-1">
|
|
302
|
+
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Output Shape</label>
|
|
303
|
+
<div className="font-mono text-xs font-medium text-emerald-600 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/20 px-2 py-1 rounded-md inline-block border border-emerald-100 dark:border-emerald-800/30">
|
|
304
|
+
[{modelInfo.output_shape.map(s => s || '?').join(', ')}]
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{modelInfo.layer_count !== undefined && (
|
|
310
|
+
<div className="space-y-1">
|
|
311
|
+
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Layers</label>
|
|
312
|
+
<div className="font-semibold text-slate-700 dark:text-slate-300">{modelInfo.layer_count}</div>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{modelInfo.total_params !== undefined && (
|
|
317
|
+
<div className="space-y-1">
|
|
318
|
+
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Total Parameters</label>
|
|
319
|
+
<div className="font-mono font-medium text-slate-700 dark:text-slate-300">
|
|
320
|
+
{modelInfo.total_params.toLocaleString()}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{modelInfo.input_features && (
|
|
326
|
+
<div className="col-span-full space-y-2">
|
|
327
|
+
<div className="flex items-center justify-between border-b border-slate-100 dark:border-slate-800 pb-1">
|
|
328
|
+
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Feature Names</label>
|
|
329
|
+
<span className="text-[10px] bg-slate-100 px-1.5 rounded-full text-slate-500">{modelInfo.input_features.length} features</span>
|
|
330
|
+
</div>
|
|
331
|
+
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto pr-2 scrollbar-thin">
|
|
332
|
+
{Array.isArray(modelInfo.input_features) ? modelInfo.input_features.map(f => (
|
|
333
|
+
<span key={f} className="text-[10px] bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-md px-2 py-1 text-slate-600 dark:text-slate-400 font-mono">{f}</span>
|
|
334
|
+
)) : <span className="text-xs text-slate-500 italic">{modelInfo.input_features}</span>}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
</motion.div>
|
|
340
|
+
);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export function ModelExplorer() {
|
|
344
|
+
const [deployments, setDeployments] = useState([]);
|
|
345
|
+
const [selectedModels, setSelectedModels] = useState([]); // Support multi-select for comparison
|
|
346
|
+
const [schema, setSchema] = useState(null);
|
|
347
|
+
const [inputs, setInputs] = useState({});
|
|
348
|
+
const [predictions, setPredictions] = useState([]); // Array for comparison
|
|
349
|
+
const [history, setHistory] = useState([]);
|
|
350
|
+
const [sweepResults, setSweepResults] = useState(null);
|
|
351
|
+
const [loading, setLoading] = useState(true);
|
|
352
|
+
const [predicting, setPredicting] = useState(false);
|
|
353
|
+
const [sweeping, setSweeping] = useState(false);
|
|
354
|
+
|
|
355
|
+
// UI State
|
|
356
|
+
const [inputMode, setInputMode] = useState('form'); // 'form' | 'json'
|
|
357
|
+
const [viewMode, setViewMode] = useState('single'); // 'single' | 'compare'
|
|
358
|
+
const [copied, setCopied] = useState(false);
|
|
359
|
+
const [error, setError] = useState(null);
|
|
360
|
+
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
|
361
|
+
const [activeTab, setActiveTab] = useState('results'); // 'results', 'history', 'logs', 'info'
|
|
362
|
+
|
|
363
|
+
// External API connection state
|
|
364
|
+
const [connectionMode, setConnectionMode] = useState('internal');
|
|
365
|
+
const [externalEndpoint, setExternalEndpoint] = useState('');
|
|
366
|
+
const [externalApiKey, setExternalApiKey] = useState('');
|
|
367
|
+
const [externalConnected, setExternalConnected] = useState(false);
|
|
368
|
+
const [showExternalConfig, setShowExternalConfig] = useState(false);
|
|
369
|
+
|
|
370
|
+
// Sweep configuration
|
|
371
|
+
const [sweepParam, setSweepParam] = useState('');
|
|
372
|
+
const [sweepMin, setSweepMin] = useState(0);
|
|
373
|
+
const [sweepMax, setSweepMax] = useState(100);
|
|
374
|
+
const [sweepSteps, setSweepSteps] = useState(10);
|
|
375
|
+
|
|
376
|
+
// Logs streaming
|
|
377
|
+
const [logs, setLogs] = useState([]);
|
|
378
|
+
const [showLogs, setShowLogs] = useState(false);
|
|
379
|
+
const [modelInfo, setModelInfo] = useState(null);
|
|
380
|
+
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
fetchDeployments();
|
|
383
|
+
}, []);
|
|
384
|
+
|
|
385
|
+
const fetchDeployments = async () => {
|
|
386
|
+
try {
|
|
387
|
+
const response = await fetch('/api/deployments/');
|
|
388
|
+
const data = await response.json();
|
|
389
|
+
const running = (Array.isArray(data) ? data : []).filter(d => d.status === 'running');
|
|
390
|
+
setDeployments(running);
|
|
391
|
+
setError(null);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
console.error('Failed to fetch deployments:', err);
|
|
394
|
+
setError('Failed to load deployments. Please check your connection.');
|
|
395
|
+
} finally {
|
|
396
|
+
setLoading(false);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Fetch model info (real introspection from loaded model)
|
|
401
|
+
const fetchModelInfo = async (deploymentId) => {
|
|
402
|
+
try {
|
|
403
|
+
const response = await fetch(`/api/explorer/model-info/${deploymentId}`);
|
|
404
|
+
if (response.ok) {
|
|
405
|
+
const data = await response.json();
|
|
406
|
+
setModelInfo(data);
|
|
407
|
+
|
|
408
|
+
// If we have input_features, generate appropriate input fields
|
|
409
|
+
if (data.input_features) {
|
|
410
|
+
const newInputs = {};
|
|
411
|
+
for (let i = 0; i < data.input_features; i++) {
|
|
412
|
+
const name = data.input_names?.[i] || `feature_${i}`;
|
|
413
|
+
newInputs[name] = 0.5;
|
|
414
|
+
}
|
|
415
|
+
setInputs(newInputs);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
console.error('Failed to fetch model info:', err);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// Fetch deployment logs
|
|
424
|
+
const fetchLogs = async (deploymentId) => {
|
|
425
|
+
try {
|
|
426
|
+
const response = await fetch(`/api/explorer/logs/${deploymentId}?lines=50`);
|
|
427
|
+
if (response.ok) {
|
|
428
|
+
const data = await response.json();
|
|
429
|
+
setLogs(data.logs || []);
|
|
430
|
+
}
|
|
431
|
+
} catch (err) {
|
|
432
|
+
console.error('Failed to fetch logs:', err);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Poll logs when a model is selected and activeTab is logs
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
if (selectedModels.length > 0 && activeTab === 'logs') {
|
|
439
|
+
fetchLogs(selectedModels[0].id);
|
|
440
|
+
const interval = setInterval(() => {
|
|
441
|
+
fetchLogs(selectedModels[0].id);
|
|
442
|
+
}, 2000);
|
|
443
|
+
return () => clearInterval(interval);
|
|
444
|
+
}
|
|
445
|
+
}, [selectedModels, activeTab]);
|
|
446
|
+
|
|
447
|
+
const selectModel = async (deployment, addToComparison = false) => {
|
|
448
|
+
if (viewMode === 'compare' && addToComparison) {
|
|
449
|
+
if (selectedModels.length < 2 && !selectedModels.find(m => m.id === deployment.id)) {
|
|
450
|
+
setSelectedModels(prev => [...prev, deployment]);
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
setSelectedModels([deployment]);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const response = await fetch(`/api/explorer/schema/${deployment.model_artifact_id}`);
|
|
458
|
+
const data = await response.json();
|
|
459
|
+
setSchema(data);
|
|
460
|
+
setSweepResults(null); // Clear previous sweep results
|
|
461
|
+
setPredictions([]);
|
|
462
|
+
setLogs([]); // Clear previous logs
|
|
463
|
+
setError(null);
|
|
464
|
+
|
|
465
|
+
const initialInputs = {};
|
|
466
|
+
data.inputs?.forEach(field => {
|
|
467
|
+
if (field.default !== null && field.default !== undefined) {
|
|
468
|
+
initialInputs[field.name] = field.default;
|
|
469
|
+
} else if (field.type === 'number') {
|
|
470
|
+
initialInputs[field.name] = (field.min_value || 0) + ((field.max_value || 100) - (field.min_value || 0)) / 2;
|
|
471
|
+
} else if (field.type === 'integer') {
|
|
472
|
+
initialInputs[field.name] = Math.floor((field.min_value || 0) + ((field.max_value || 100) - (field.min_value || 0)) / 2);
|
|
473
|
+
} else if (field.type === 'object' || field.type === 'array') {
|
|
474
|
+
initialInputs[field.name] = field.default || {};
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
setInputs(initialInputs);
|
|
478
|
+
setSweepParam(data.inputs?.find(f => f.type === 'number' || f.type === 'integer')?.name || '');
|
|
479
|
+
|
|
480
|
+
// Also fetch real model info for the deployment
|
|
481
|
+
fetchModelInfo(deployment.id);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
console.error('Failed to fetch schema:', err);
|
|
484
|
+
setError('Failed to load model schema. Using default inputs.');
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const runPrediction = async () => {
|
|
489
|
+
if (selectedModels.length === 0) return;
|
|
490
|
+
setPredicting(true);
|
|
491
|
+
setError(null);
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const results = await Promise.all(
|
|
495
|
+
selectedModels.map(async (model) => {
|
|
496
|
+
const startTime = Date.now();
|
|
497
|
+
const response = await fetch('/api/explorer/predict', {
|
|
498
|
+
method: 'POST',
|
|
499
|
+
headers: { 'Content-Type': 'application/json' },
|
|
500
|
+
body: JSON.stringify({
|
|
501
|
+
deployment_id: model.id,
|
|
502
|
+
inputs: inputs
|
|
503
|
+
})
|
|
504
|
+
});
|
|
505
|
+
const result = await response.json();
|
|
506
|
+
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
throw new Error(result.detail || 'Prediction failed');
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
...result,
|
|
513
|
+
model_name: model.name,
|
|
514
|
+
source: 'internal'
|
|
515
|
+
};
|
|
516
|
+
})
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
setPredictions(results);
|
|
520
|
+
setHistory(prev => [...results.map(r => ({ ...r, inputs: { ...inputs } })), ...prev].slice(0, 50));
|
|
521
|
+
} catch (err) {
|
|
522
|
+
console.error('Prediction failed:', err);
|
|
523
|
+
setError(`Prediction failed: ${err.message}`);
|
|
524
|
+
} finally {
|
|
525
|
+
setPredicting(false);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const runExternalPrediction = async () => {
|
|
530
|
+
if (!externalEndpoint) return;
|
|
531
|
+
setPredicting(true);
|
|
532
|
+
setError(null);
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
const startTime = Date.now();
|
|
536
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
537
|
+
if (externalApiKey) {
|
|
538
|
+
headers['Authorization'] = `Bearer ${externalApiKey}`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const response = await fetch(externalEndpoint, {
|
|
542
|
+
method: 'POST',
|
|
543
|
+
headers,
|
|
544
|
+
body: JSON.stringify(inputs)
|
|
545
|
+
});
|
|
546
|
+
const data = await response.json();
|
|
547
|
+
|
|
548
|
+
const result = {
|
|
549
|
+
id: Date.now().toString(),
|
|
550
|
+
outputs: data.predictions || data.outputs || data,
|
|
551
|
+
inputs: { ...inputs },
|
|
552
|
+
latency_ms: Date.now() - startTime,
|
|
553
|
+
timestamp: new Date().toISOString(),
|
|
554
|
+
source: 'external'
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
setPredictions([result]);
|
|
558
|
+
setHistory(prev => [result, ...prev].slice(0, 50));
|
|
559
|
+
} catch (err) {
|
|
560
|
+
console.error('External prediction failed:', err);
|
|
561
|
+
setError(`External API call failed: ${err.message}`);
|
|
562
|
+
} finally {
|
|
563
|
+
setPredicting(false);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const runSweep = async () => {
|
|
568
|
+
if (selectedModels.length === 0 || !sweepParam) return;
|
|
569
|
+
setSweeping(true);
|
|
570
|
+
setError(null);
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const sweepValues = [];
|
|
574
|
+
const step = (sweepMax - sweepMin) / (sweepSteps - 1);
|
|
575
|
+
for (let i = 0; i < sweepSteps; i++) {
|
|
576
|
+
sweepValues.push(sweepMin + step * i);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const response = await fetch('/api/explorer/sweep', {
|
|
580
|
+
method: 'POST',
|
|
581
|
+
headers: { 'Content-Type': 'application/json' },
|
|
582
|
+
body: JSON.stringify({
|
|
583
|
+
deployment_id: selectedModels[0].id,
|
|
584
|
+
base_inputs: inputs,
|
|
585
|
+
sweep_param: sweepParam,
|
|
586
|
+
sweep_values: sweepValues
|
|
587
|
+
})
|
|
588
|
+
});
|
|
589
|
+
const result = await response.json();
|
|
590
|
+
|
|
591
|
+
if (!response.ok) {
|
|
592
|
+
throw new Error(result.detail || 'Sweep failed');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
setSweepResults(result);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
console.error('Sweep failed:', err);
|
|
598
|
+
setError(`Parameter sweep failed: ${err.message}`);
|
|
599
|
+
} finally {
|
|
600
|
+
setSweeping(false);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const updateInput = (name, value) => {
|
|
605
|
+
setInputs(prev => ({ ...prev, [name]: value }));
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const copyInputs = () => {
|
|
609
|
+
navigator.clipboard.writeText(JSON.stringify(inputs, null, 2));
|
|
610
|
+
setCopied(true);
|
|
611
|
+
setTimeout(() => setCopied(false), 2000);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const loadFromHistory = (item) => {
|
|
615
|
+
setInputs(item.inputs);
|
|
616
|
+
setPredictions([item]);
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
if (loading) {
|
|
620
|
+
return (
|
|
621
|
+
<div className="flex items-center justify-center min-h-screen">
|
|
622
|
+
<div className="text-center">
|
|
623
|
+
<Loader2 className="w-12 h-12 animate-spin text-violet-500 mx-auto mb-4" />
|
|
624
|
+
<p className="text-slate-500">Loading Model Explorer...</p>
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<div className="p-6 h-[calc(100vh-20px)] overflow-hidden flex flex-col max-w-full mx-auto">
|
|
632
|
+
{/* Header */}
|
|
633
|
+
<div className="flex items-center justify-between mb-6 shrink-0">
|
|
634
|
+
<div className="flex items-center gap-4">
|
|
635
|
+
<div className="p-3 bg-gradient-to-br from-violet-500 to-purple-600 rounded-xl text-white shadow-lg shadow-violet-500/20">
|
|
636
|
+
<Microscope size={28} />
|
|
637
|
+
</div>
|
|
638
|
+
<div>
|
|
639
|
+
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">Model Explorer</h1>
|
|
640
|
+
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
|
641
|
+
Test, compare, and analyze ML models interactively
|
|
642
|
+
</p>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
<div className="flex items-center gap-3">
|
|
646
|
+
<div className="flex bg-slate-100 dark:bg-slate-800 rounded-lg p-1">
|
|
647
|
+
<button
|
|
648
|
+
onClick={() => { setViewMode('single'); setSelectedModels(selectedModels.slice(0, 1)); }}
|
|
649
|
+
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${viewMode === 'single'
|
|
650
|
+
? 'bg-white dark:bg-slate-700 text-violet-600 shadow-sm'
|
|
651
|
+
: 'text-slate-500 hover:text-slate-700'
|
|
652
|
+
}`}
|
|
653
|
+
>
|
|
654
|
+
<Target size={14} />
|
|
655
|
+
Single
|
|
656
|
+
</button>
|
|
657
|
+
<button
|
|
658
|
+
onClick={() => setViewMode('compare')}
|
|
659
|
+
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${viewMode === 'compare'
|
|
660
|
+
? 'bg-white dark:bg-slate-700 text-violet-600 shadow-sm'
|
|
661
|
+
: 'text-slate-500 hover:text-slate-700'
|
|
662
|
+
}`}
|
|
663
|
+
>
|
|
664
|
+
<GitCompare size={14} />
|
|
665
|
+
Compare
|
|
666
|
+
</button>
|
|
667
|
+
</div>
|
|
668
|
+
|
|
669
|
+
<div className="flex bg-slate-100 dark:bg-slate-800 rounded-lg p-1">
|
|
670
|
+
<button
|
|
671
|
+
onClick={() => { setConnectionMode('internal'); setShowExternalConfig(false); }}
|
|
672
|
+
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${connectionMode === 'internal'
|
|
673
|
+
? 'bg-white dark:bg-slate-700 text-emerald-600 shadow-sm'
|
|
674
|
+
: 'text-slate-500 hover:text-slate-700'
|
|
675
|
+
}`}
|
|
676
|
+
>
|
|
677
|
+
<Zap size={14} />
|
|
678
|
+
FlowyML
|
|
679
|
+
</button>
|
|
680
|
+
<button
|
|
681
|
+
onClick={() => { setConnectionMode('external'); setShowExternalConfig(true); }}
|
|
682
|
+
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${connectionMode === 'external'
|
|
683
|
+
? 'bg-white dark:bg-slate-700 text-blue-600 shadow-sm'
|
|
684
|
+
: 'text-slate-500 hover:text-slate-700'
|
|
685
|
+
}`}
|
|
686
|
+
>
|
|
687
|
+
<Globe size={14} />
|
|
688
|
+
External
|
|
689
|
+
</button>
|
|
690
|
+
</div>
|
|
691
|
+
|
|
692
|
+
<Button onClick={fetchDeployments} variant="ghost" className="flex items-center gap-2">
|
|
693
|
+
<RefreshCw size={16} />
|
|
694
|
+
Refresh
|
|
695
|
+
</Button>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
|
|
699
|
+
{/* Error Banner */}
|
|
700
|
+
<AnimatePresence>
|
|
701
|
+
{error && (
|
|
702
|
+
<motion.div
|
|
703
|
+
initial={{ opacity: 0, height: 0 }}
|
|
704
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
705
|
+
exit={{ opacity: 0, height: 0 }}
|
|
706
|
+
className="mb-4"
|
|
707
|
+
>
|
|
708
|
+
<div className="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg p-4 flex items-center gap-3">
|
|
709
|
+
<AlertCircle className="text-rose-500" size={20} />
|
|
710
|
+
<p className="text-rose-700 dark:text-rose-400 text-sm flex-1">{error}</p>
|
|
711
|
+
<button onClick={() => setError(null)} className="text-rose-500 hover:text-rose-700">×</button>
|
|
712
|
+
</div>
|
|
713
|
+
</motion.div>
|
|
714
|
+
)}
|
|
715
|
+
</AnimatePresence>
|
|
716
|
+
|
|
717
|
+
{/* External API Config */}
|
|
718
|
+
<AnimatePresence>
|
|
719
|
+
{showExternalConfig && (
|
|
720
|
+
<motion.div
|
|
721
|
+
initial={{ opacity: 0, height: 0 }}
|
|
722
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
723
|
+
exit={{ opacity: 0, height: 0 }}
|
|
724
|
+
className="mb-6 shrink-0"
|
|
725
|
+
>
|
|
726
|
+
<Card className="bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 border-violet-200 dark:border-violet-800">
|
|
727
|
+
{/* ... external config content reused ... */}
|
|
728
|
+
<div className="flex items-center gap-2 mb-4">
|
|
729
|
+
<Globe size={18} className="text-violet-500" />
|
|
730
|
+
<h3 className="font-semibold text-slate-900 dark:text-white">External API Connection</h3>
|
|
731
|
+
{externalConnected && <Badge className="bg-emerald-100 text-emerald-700 ml-2">Connected</Badge>}
|
|
732
|
+
</div>
|
|
733
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
734
|
+
<div className="md:col-span-2">
|
|
735
|
+
<label className="text-xs font-medium text-slate-500 mb-1 block">Endpoint URL</label>
|
|
736
|
+
<input type="url" value={externalEndpoint} onChange={(e) => setExternalEndpoint(e.target.value)} placeholder="https://api.example.com/v1/predict" className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800" />
|
|
737
|
+
</div>
|
|
738
|
+
<div>
|
|
739
|
+
<label className="text-xs font-medium text-slate-500 mb-1 block">API Key (optional)</label>
|
|
740
|
+
<input type="password" value={externalApiKey} onChange={(e) => setExternalApiKey(e.target.value)} placeholder="Bearer token..." className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800" />
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
</Card>
|
|
744
|
+
</motion.div>
|
|
745
|
+
)}
|
|
746
|
+
</AnimatePresence>
|
|
747
|
+
|
|
748
|
+
{/* Main Layout Grid */}
|
|
749
|
+
<div className="grid grid-cols-12 gap-6 flex-1 min-h-0">
|
|
750
|
+
|
|
751
|
+
{/* LEFT SIDEBAR - CONFIGURATION */}
|
|
752
|
+
<div className="col-span-3 h-full border-r border-slate-200/50 dark:border-slate-700/50 bg-white/60 dark:bg-slate-900/60 backdrop-blur-xl flex flex-col shadow-[4px_0_24px_rgba(0,0,0,0.02)] z-20">
|
|
753
|
+
<div className="p-4 flex-1 overflow-y-auto space-y-6 scrollbar-thin scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700">
|
|
754
|
+
{/* Model Selection */}
|
|
755
|
+
<div className="space-y-3">
|
|
756
|
+
<h3 className="text-[10px] font-bold text-slate-400 uppercase tracking-widest flex items-center gap-2 px-1">
|
|
757
|
+
<Layers size={12} />
|
|
758
|
+
Model Selection
|
|
759
|
+
</h3>
|
|
760
|
+
{deployments.length === 0 ? (
|
|
761
|
+
<div className="text-center py-8 border-2 border-dashed border-slate-200 rounded-xl text-slate-400 text-xs">
|
|
762
|
+
No active deployments
|
|
763
|
+
</div>
|
|
764
|
+
) : (
|
|
765
|
+
<div className="space-y-2">
|
|
766
|
+
{deployments.map(d => {
|
|
767
|
+
const isSelected = selectedModels.find(m => m.id === d.id);
|
|
768
|
+
return (
|
|
769
|
+
<button
|
|
770
|
+
key={d.id}
|
|
771
|
+
onClick={() => selectModel(d, viewMode === 'compare')}
|
|
772
|
+
className={`w-full text-left p-3 rounded-xl border transition-all duration-200 group relative overflow-hidden ${isSelected
|
|
773
|
+
? 'border-violet-500/50 bg-gradient-to-r from-violet-500/10 to-indigo-500/10 shadow-sm'
|
|
774
|
+
: 'border-transparent hover:bg-white/50 dark:hover:bg-slate-800/50 hover:border-slate-200 dark:hover:border-slate-700'
|
|
775
|
+
}`}
|
|
776
|
+
>
|
|
777
|
+
<div className="flex items-center justify-between relative z-10">
|
|
778
|
+
<div className={`font-medium text-sm transition-colors ${isSelected ? 'text-violet-700 dark:text-violet-300' : 'text-slate-600 dark:text-slate-300'}`}>
|
|
779
|
+
{d.name}
|
|
780
|
+
</div>
|
|
781
|
+
{isSelected && (
|
|
782
|
+
<motion.div layoutId="check" initial={{ scale: 0 }} animate={{ scale: 1 }}>
|
|
783
|
+
<div className="bg-violet-500 text-white p-0.5 rounded-full">
|
|
784
|
+
<Check size={10} strokeWidth={3} />
|
|
785
|
+
</div>
|
|
786
|
+
</motion.div>
|
|
787
|
+
)}
|
|
788
|
+
</div>
|
|
789
|
+
<div className="text-[10px] text-slate-400 mt-1 pl-0.5">{d.id}</div>
|
|
790
|
+
{isSelected && <div className="absolute left-0 top-0 bottom-0 w-1 bg-violet-500" />}
|
|
791
|
+
</button>
|
|
792
|
+
);
|
|
793
|
+
})}
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
</div>
|
|
797
|
+
|
|
798
|
+
{/* Inputs */}
|
|
799
|
+
<div className="space-y-3 pt-6 border-t border-slate-200/50 dark:border-slate-700/50">
|
|
800
|
+
<div className="flex items-center justify-between px-1">
|
|
801
|
+
<h3 className="text-[10px] font-bold text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
|
802
|
+
<Sliders size={12} />
|
|
803
|
+
Parameters
|
|
804
|
+
</h3>
|
|
805
|
+
<div className="flex bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5 border border-slate-200 dark:border-slate-700">
|
|
806
|
+
<button onClick={() => setInputMode('form')} className={`px-2 py-0.5 rounded-md text-[10px] font-medium transition-all ${inputMode === 'form' ? 'bg-white shadow-sm text-violet-600' : 'text-slate-400 hover:text-slate-600'}`}>Form</button>
|
|
807
|
+
<button onClick={() => setInputMode('json')} className={`px-2 py-0.5 rounded-md text-[10px] font-medium transition-all ${inputMode === 'json' ? 'bg-white shadow-sm text-violet-600' : 'text-slate-400 hover:text-slate-600'}`}>JSON</button>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
|
|
811
|
+
<div className="min-h-[200px]">
|
|
812
|
+
{inputMode === 'json' ? (
|
|
813
|
+
<div className="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden shadow-sm">
|
|
814
|
+
<JsonEditor value={inputs} onChange={setInputs} />
|
|
815
|
+
</div>
|
|
816
|
+
) : (
|
|
817
|
+
<div className="space-y-4 px-1">
|
|
818
|
+
{schema?.inputs?.map(field => (
|
|
819
|
+
<div key={field.name} className="group">
|
|
820
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
821
|
+
<label className="text-xs font-semibold text-slate-600 dark:text-slate-300 group-hover:text-violet-600 transition-colors">{field.name}</label>
|
|
822
|
+
<span className="text-[10px] text-slate-400 font-mono bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded border border-slate-200 dark:border-slate-700">{formatValue(inputs[field.name])?.slice(0, 10)}</span>
|
|
823
|
+
</div>
|
|
824
|
+
{field.type === 'number' || field.type === 'integer' ? (
|
|
825
|
+
<div className="relative flex items-center">
|
|
826
|
+
<input
|
|
827
|
+
type="range"
|
|
828
|
+
min={field.min_value || 0}
|
|
829
|
+
max={field.max_value || 100}
|
|
830
|
+
step={field.step || (field.type === 'integer' ? 1 : 0.1)}
|
|
831
|
+
value={inputs[field.name] || 0}
|
|
832
|
+
onChange={(e) => updateInput(field.name, parseFloat(e.target.value))}
|
|
833
|
+
className="w-full h-1.5 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-violet-500 hover:accent-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-500/20"
|
|
834
|
+
/>
|
|
835
|
+
</div>
|
|
836
|
+
) : field.type === 'boolean' ? (
|
|
837
|
+
<div className="flex gap-2">
|
|
838
|
+
<button onClick={() => updateInput(field.name, true)} className={`flex-1 py-1.5 text-xs font-medium rounded-lg border transition-all ${inputs[field.name] ? 'bg-violet-500 border-violet-600 text-white shadow-md shadow-violet-500/20' : 'bg-white border-slate-200 text-slate-500 hover:bg-slate-50'}`}>True</button>
|
|
839
|
+
<button onClick={() => updateInput(field.name, false)} className={`flex-1 py-1.5 text-xs font-medium rounded-lg border transition-all ${!inputs[field.name] ? 'bg-slate-700 border-slate-800 text-white shadow-md' : 'bg-white border-slate-200 text-slate-500 hover:bg-slate-50'}`}>False</button>
|
|
840
|
+
</div>
|
|
841
|
+
) : (
|
|
842
|
+
<input
|
|
843
|
+
type="text"
|
|
844
|
+
value={inputs[field.name] || ''}
|
|
845
|
+
onChange={(e) => updateInput(field.name, e.target.value)}
|
|
846
|
+
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 text-sm bg-white dark:bg-slate-800 focus:ring-2 focus:ring-violet-500/20 focus:border-violet-500 transition-all placeholder:text-slate-300"
|
|
847
|
+
placeholder={`Enter ${field.name}...`}
|
|
848
|
+
/>
|
|
849
|
+
)}
|
|
850
|
+
</div>
|
|
851
|
+
)) || <div className="text-sm text-slate-400 text-center py-10 bg-slate-50/50 rounded-xl border border-dashed border-slate-200">Select a model to configure inputs</div>}
|
|
852
|
+
</div>
|
|
853
|
+
)}
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<div className="p-4 border-t border-slate-200/60 dark:border-slate-700/60 bg-white/50 dark:bg-slate-900/50 backdrop-blur-md">
|
|
859
|
+
<Button
|
|
860
|
+
onClick={connectionMode === 'external' ? runExternalPrediction : runPrediction}
|
|
861
|
+
disabled={predicting || selectedModels.length === 0}
|
|
862
|
+
className={`w-full py-6 text-sm font-bold tracking-wide shadow-xl shadow-violet-500/20 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl transition-all active:scale-[0.98] ${predicting ? 'opacity-80' : ''}`}
|
|
863
|
+
>
|
|
864
|
+
{predicting ? <Loader2 className="animate-spin mr-2" /> : <Play className="mr-2 fill-current" size={18} />}
|
|
865
|
+
{predicting ? 'RUNNING...' : 'RUN PREDICTION'}
|
|
866
|
+
</Button>
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
869
|
+
|
|
870
|
+
{/* RIGHT MAIN - TABS & CONTENT */}
|
|
871
|
+
<div className="col-span-9 flex flex-col h-full overflow-hidden bg-white/30 dark:bg-slate-900/30 backdrop-blur-sm relative">
|
|
872
|
+
{/* Fluid Mesh Background for Main Content */}
|
|
873
|
+
<div className="absolute inset-0 bg-gradient-to-br from-white/40 via-transparent to-white/40 pointer-events-none" />
|
|
874
|
+
|
|
875
|
+
{/* Tab Header */}
|
|
876
|
+
<div className="flex items-center gap-6 border-b border-slate-200/60 dark:border-slate-700/60 px-8 bg-white/60 dark:bg-slate-900/60 backdrop-blur-xl sticky top-0 z-10 h-16 shrink-0">
|
|
877
|
+
{[
|
|
878
|
+
{ id: 'results', icon: Sparkles, label: 'Results' },
|
|
879
|
+
{ id: 'history', icon: History, label: 'History' },
|
|
880
|
+
{ id: 'logs', icon: Terminal, label: 'Live Logs' },
|
|
881
|
+
{ id: 'info', icon: Info, label: 'Model Specs' }
|
|
882
|
+
].map(tab => (
|
|
883
|
+
<button
|
|
884
|
+
key={tab.id}
|
|
885
|
+
onClick={() => setActiveTab(tab.id)}
|
|
886
|
+
className={`relative h-full flex items-center gap-2.5 text-sm font-medium transition-colors ${activeTab === tab.id
|
|
887
|
+
? 'text-violet-700 dark:text-violet-400'
|
|
888
|
+
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
|
889
|
+
}`}
|
|
890
|
+
>
|
|
891
|
+
<tab.icon size={16} className={activeTab === tab.id ? 'stroke-[2.5px]' : ''} />
|
|
892
|
+
{tab.label}
|
|
893
|
+
{tab.id === 'history' && history.length > 0 && (
|
|
894
|
+
<span className="bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-[10px] font-bold px-1.5 py-0.5 rounded-full border border-slate-200 dark:border-slate-700">{history.length}</span>
|
|
895
|
+
)}
|
|
896
|
+
{activeTab === tab.id && (
|
|
897
|
+
<motion.div
|
|
898
|
+
layoutId="activeTabIndicator"
|
|
899
|
+
className="absolute bottom-0 left-0 right-0 h-[3px] bg-gradient-to-r from-violet-500 to-indigo-500 rounded-t-full shadow-[0_-4px_12px_rgba(139,92,246,0.5)]"
|
|
900
|
+
/>
|
|
901
|
+
)}
|
|
902
|
+
</button>
|
|
903
|
+
))}
|
|
904
|
+
</div>
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
{/* Content Area */}
|
|
908
|
+
<div className="flex-1 overflow-y-auto p-6 bg-slate-50/30 dark:bg-slate-900/10 relative">
|
|
909
|
+
{activeTab === 'results' && (
|
|
910
|
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-6">
|
|
911
|
+
{/* Result Cards */}
|
|
912
|
+
{predictions.length > 0 ? (
|
|
913
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
914
|
+
{/* Main Result */}
|
|
915
|
+
<Card className="md:col-span-2 bg-gradient-to-br from-violet-600 to-purple-700 text-white border-none shadow-xl shadow-violet-500/20">
|
|
916
|
+
<div className="text-violet-200 text-xs font-bold uppercase tracking-wider mb-2">Primary Prediction</div>
|
|
917
|
+
<div className="text-5xl font-bold font-mono tracking-tight mb-4">
|
|
918
|
+
{formatValue(predictions[0]?.outputs?.prediction || Object.values(predictions[0]?.outputs || {})[0])}
|
|
919
|
+
</div>
|
|
920
|
+
<div className="flex gap-4 text-violet-200 text-sm">
|
|
921
|
+
<span className="flex items-center gap-1"><Activity size={14} /> {predictions[0]?.latency_ms?.toFixed(1)}ms</span>
|
|
922
|
+
<span className="flex items-center gap-1"><Layers size={14} /> {predictions[0]?.model_name || 'Unknown Model'}</span>
|
|
923
|
+
</div>
|
|
924
|
+
</Card>
|
|
925
|
+
|
|
926
|
+
{/* Secondary Metrics / Raw Output */}
|
|
927
|
+
{Object.entries(predictions[0]?.outputs || {}).filter(([k]) => k !== 'prediction').map(([key, value]) => (
|
|
928
|
+
<Card key={key} className="bg-white dark:bg-slate-800">
|
|
929
|
+
<div className="text-xs text-slate-500 uppercase font-bold mb-1">{key}</div>
|
|
930
|
+
<div className="text-lg font-mono text-slate-800 dark:text-slate-200 truncate" title={formatValue(value)}>
|
|
931
|
+
{formatValue(value)}
|
|
932
|
+
</div>
|
|
933
|
+
</Card>
|
|
934
|
+
))}
|
|
935
|
+
</div>
|
|
936
|
+
) : (
|
|
937
|
+
<div className="text-center py-20 opacity-50">
|
|
938
|
+
<Sparkles size={48} className="mx-auto mb-4 text-slate-300" />
|
|
939
|
+
<h3 className="text-lg font-medium text-slate-500">Ready to Predict</h3>
|
|
940
|
+
<p className="text-sm text-slate-400">Select a model and run a prediction to see results</p>
|
|
941
|
+
</div>
|
|
942
|
+
)}
|
|
943
|
+
|
|
944
|
+
{/* Sensitivity Analysis (Moved here) */}
|
|
945
|
+
{selectedModels.length > 0 && schema && (
|
|
946
|
+
<div className="mt-8 border-t border-slate-200 pt-6">
|
|
947
|
+
<h4 className="font-semibold text-slate-700 mb-4 flex items-center gap-2">
|
|
948
|
+
<TrendingUp size={16} className="text-violet-500" />
|
|
949
|
+
Sensitivity Analysis
|
|
950
|
+
</h4>
|
|
951
|
+
<div className="flex gap-4 items-end bg-white p-4 rounded-xl border border-slate-200">
|
|
952
|
+
<div className="flex-1">
|
|
953
|
+
<label className="text-xs text-slate-500 font-bold mb-1 block">Parameter to Sweep</label>
|
|
954
|
+
<select
|
|
955
|
+
value={sweepParam}
|
|
956
|
+
onChange={(e) => setSweepParam(e.target.value)}
|
|
957
|
+
className="w-full px-3 py-2 border rounded-lg text-sm bg-slate-50"
|
|
958
|
+
>
|
|
959
|
+
{schema.inputs?.filter(f => f.type === 'number' || f.type === 'integer').map(f => (
|
|
960
|
+
<option key={f.name} value={f.name}>{f.name}</option>
|
|
961
|
+
))}
|
|
962
|
+
</select>
|
|
963
|
+
</div>
|
|
964
|
+
<Button onClick={runSweep} disabled={sweeping} variant="outline" className="mb-0.5">
|
|
965
|
+
{sweeping ? <Loader2 className="animate-spin" /> : <Play size={14} className="mr-2" />}
|
|
966
|
+
Run Sweep
|
|
967
|
+
</Button>
|
|
968
|
+
</div>
|
|
969
|
+
{sweepResults && (
|
|
970
|
+
<div className="mt-4">
|
|
971
|
+
<PredictionChart
|
|
972
|
+
data={sweepResults.results?.map((r, i) => ({
|
|
973
|
+
value: r.outputs?.prediction || 0,
|
|
974
|
+
label: r.input_value?.toFixed(1)
|
|
975
|
+
})) || []}
|
|
976
|
+
title={`${sweepParam} Sensitivity`}
|
|
977
|
+
/>
|
|
978
|
+
</div>
|
|
979
|
+
)}
|
|
980
|
+
</div>
|
|
981
|
+
)}
|
|
982
|
+
</motion.div>
|
|
983
|
+
)}
|
|
984
|
+
|
|
985
|
+
{activeTab === 'history' && (
|
|
986
|
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-2">
|
|
987
|
+
{history.map((h, i) => (
|
|
988
|
+
<div key={i} onClick={() => loadFromHistory(h)} className="flex items-center justify-between p-4 bg-white border border-slate-100 rounded-xl hover:shadow-md cursor-pointer transition-all group">
|
|
989
|
+
<div className="flex items-center gap-4">
|
|
990
|
+
<div className="w-10 h-10 rounded-full bg-violet-100 text-violet-600 flex items-center justify-center font-bold text-xs">
|
|
991
|
+
#{history.length - i}
|
|
992
|
+
</div>
|
|
993
|
+
<div>
|
|
994
|
+
<div className="font-mono font-bold text-slate-800">{formatValue(h?.outputs?.prediction)}</div>
|
|
995
|
+
<div className="text-xs text-slate-500">{new Date(h.timestamp).toLocaleString()}</div>
|
|
996
|
+
</div>
|
|
997
|
+
</div>
|
|
998
|
+
<div className="text-right">
|
|
999
|
+
<div className="text-xs font-bold text-slate-600">{h.latency_ms?.toFixed(0)}ms</div>
|
|
1000
|
+
<div className="text-[10px] text-slate-400 group-hover:text-violet-500">Restore Inputs</div>
|
|
1001
|
+
</div>
|
|
1002
|
+
</div>
|
|
1003
|
+
))}
|
|
1004
|
+
{history.length === 0 && <div className="text-center py-10 text-slate-400">No history yet</div>}
|
|
1005
|
+
</motion.div>
|
|
1006
|
+
)}
|
|
1007
|
+
|
|
1008
|
+
{activeTab === 'logs' && selectedModels.length > 0 && (
|
|
1009
|
+
<LogsPanel logs={logs} deploymentId={selectedModels[0]?.id} onClose={() => { }} />
|
|
1010
|
+
)}
|
|
1011
|
+
|
|
1012
|
+
{activeTab === 'info' && modelInfo && (
|
|
1013
|
+
<ModelInfoPanel modelInfo={modelInfo} onClose={() => { }} />
|
|
1014
|
+
)}
|
|
1015
|
+
|
|
1016
|
+
{(activeTab === 'logs' || activeTab === 'info') && !selectedModels.length && (
|
|
1017
|
+
<div className="text-center py-20 text-slate-400">Select a deployment to view {activeTab}</div>
|
|
1018
|
+
)}
|
|
1019
|
+
</div>
|
|
1020
|
+
</div>
|
|
1021
|
+
</div>
|
|
1022
|
+
{/* Footer */}
|
|
1023
|
+
<div className="text-center mt-2 shrink-0">
|
|
1024
|
+
<p className="text-[10px] text-slate-400 dark:text-slate-500">
|
|
1025
|
+
Made with ❤️ by <span className="font-medium text-violet-500">UnicoLab</span>
|
|
1026
|
+
</p>
|
|
1027
|
+
</div>
|
|
1028
|
+
</div>
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
}
|