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.
Files changed (126) hide show
  1. flowyml/assets/base.py +15 -0
  2. flowyml/assets/metrics.py +5 -0
  3. flowyml/cli/main.py +709 -0
  4. flowyml/cli/stack_cli.py +138 -25
  5. flowyml/core/__init__.py +17 -0
  6. flowyml/core/executor.py +161 -26
  7. flowyml/core/image_builder.py +129 -0
  8. flowyml/core/log_streamer.py +227 -0
  9. flowyml/core/orchestrator.py +22 -2
  10. flowyml/core/pipeline.py +34 -10
  11. flowyml/core/routing.py +558 -0
  12. flowyml/core/step.py +9 -1
  13. flowyml/core/step_grouping.py +49 -35
  14. flowyml/core/types.py +407 -0
  15. flowyml/monitoring/alerts.py +10 -0
  16. flowyml/monitoring/notifications.py +104 -25
  17. flowyml/monitoring/slack_blocks.py +323 -0
  18. flowyml/plugins/__init__.py +251 -0
  19. flowyml/plugins/alerters/__init__.py +1 -0
  20. flowyml/plugins/alerters/slack.py +168 -0
  21. flowyml/plugins/base.py +752 -0
  22. flowyml/plugins/config.py +478 -0
  23. flowyml/plugins/deployers/__init__.py +22 -0
  24. flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
  25. flowyml/plugins/deployers/sagemaker.py +306 -0
  26. flowyml/plugins/deployers/vertex.py +290 -0
  27. flowyml/plugins/integration.py +369 -0
  28. flowyml/plugins/manager.py +510 -0
  29. flowyml/plugins/model_registries/__init__.py +22 -0
  30. flowyml/plugins/model_registries/mlflow.py +159 -0
  31. flowyml/plugins/model_registries/sagemaker.py +489 -0
  32. flowyml/plugins/model_registries/vertex.py +386 -0
  33. flowyml/plugins/orchestrators/__init__.py +13 -0
  34. flowyml/plugins/orchestrators/sagemaker.py +443 -0
  35. flowyml/plugins/orchestrators/vertex_ai.py +461 -0
  36. flowyml/plugins/registries/__init__.py +13 -0
  37. flowyml/plugins/registries/ecr.py +321 -0
  38. flowyml/plugins/registries/gcr.py +313 -0
  39. flowyml/plugins/registry.py +454 -0
  40. flowyml/plugins/stack.py +494 -0
  41. flowyml/plugins/stack_config.py +537 -0
  42. flowyml/plugins/stores/__init__.py +13 -0
  43. flowyml/plugins/stores/gcs.py +460 -0
  44. flowyml/plugins/stores/s3.py +453 -0
  45. flowyml/plugins/trackers/__init__.py +11 -0
  46. flowyml/plugins/trackers/mlflow.py +316 -0
  47. flowyml/plugins/validators/__init__.py +3 -0
  48. flowyml/plugins/validators/deepchecks.py +119 -0
  49. flowyml/registry/__init__.py +2 -1
  50. flowyml/registry/model_environment.py +109 -0
  51. flowyml/registry/model_registry.py +241 -96
  52. flowyml/serving/__init__.py +17 -0
  53. flowyml/serving/model_server.py +628 -0
  54. flowyml/stacks/__init__.py +60 -0
  55. flowyml/stacks/aws.py +93 -0
  56. flowyml/stacks/base.py +62 -0
  57. flowyml/stacks/components.py +12 -0
  58. flowyml/stacks/gcp.py +44 -9
  59. flowyml/stacks/plugins.py +115 -0
  60. flowyml/stacks/registry.py +2 -1
  61. flowyml/storage/sql.py +401 -12
  62. flowyml/tracking/experiment.py +8 -5
  63. flowyml/ui/backend/Dockerfile +87 -16
  64. flowyml/ui/backend/auth.py +12 -2
  65. flowyml/ui/backend/main.py +149 -5
  66. flowyml/ui/backend/routers/ai_context.py +226 -0
  67. flowyml/ui/backend/routers/assets.py +23 -4
  68. flowyml/ui/backend/routers/auth.py +96 -0
  69. flowyml/ui/backend/routers/deployments.py +660 -0
  70. flowyml/ui/backend/routers/model_explorer.py +597 -0
  71. flowyml/ui/backend/routers/plugins.py +103 -51
  72. flowyml/ui/backend/routers/projects.py +91 -8
  73. flowyml/ui/backend/routers/runs.py +20 -1
  74. flowyml/ui/backend/routers/schedules.py +22 -17
  75. flowyml/ui/backend/routers/templates.py +319 -0
  76. flowyml/ui/backend/routers/websocket.py +2 -2
  77. flowyml/ui/frontend/Dockerfile +55 -6
  78. flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
  79. flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
  80. flowyml/ui/frontend/dist/index.html +2 -2
  81. flowyml/ui/frontend/dist/logo.png +0 -0
  82. flowyml/ui/frontend/nginx.conf +65 -4
  83. flowyml/ui/frontend/package-lock.json +1404 -74
  84. flowyml/ui/frontend/package.json +3 -0
  85. flowyml/ui/frontend/public/logo.png +0 -0
  86. flowyml/ui/frontend/src/App.jsx +10 -7
  87. flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
  88. flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
  89. flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
  90. flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
  91. flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
  92. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
  93. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +36 -24
  94. flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
  95. flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
  96. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +29 -7
  97. flowyml/ui/frontend/src/components/Layout.jsx +6 -0
  98. flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
  99. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
  100. flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
  101. flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
  102. flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
  103. flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
  104. flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
  105. flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
  106. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
  107. flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
  108. flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
  109. flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
  110. flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
  111. flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
  112. flowyml/ui/frontend/src/router/index.jsx +47 -20
  113. flowyml/ui/frontend/src/services/pluginService.js +3 -1
  114. flowyml/ui/server_manager.py +5 -5
  115. flowyml/ui/utils.py +157 -39
  116. flowyml/utils/config.py +37 -15
  117. flowyml/utils/model_introspection.py +123 -0
  118. flowyml/utils/observability.py +30 -0
  119. flowyml-1.8.0.dist-info/METADATA +174 -0
  120. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/RECORD +123 -65
  121. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
  122. flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +0 -1
  123. flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +0 -685
  124. flowyml-1.7.2.dist-info/METADATA +0 -477
  125. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
  126. {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
+ }