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,786 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
Rocket,
|
|
5
|
+
Plus,
|
|
6
|
+
Play,
|
|
7
|
+
Square,
|
|
8
|
+
Trash2,
|
|
9
|
+
RefreshCw,
|
|
10
|
+
Copy,
|
|
11
|
+
ExternalLink,
|
|
12
|
+
Terminal,
|
|
13
|
+
Shield,
|
|
14
|
+
Clock,
|
|
15
|
+
CheckCircle2,
|
|
16
|
+
AlertCircle,
|
|
17
|
+
Loader2,
|
|
18
|
+
Settings,
|
|
19
|
+
ChevronDown,
|
|
20
|
+
ChevronUp,
|
|
21
|
+
Eye,
|
|
22
|
+
EyeOff,
|
|
23
|
+
X,
|
|
24
|
+
Package,
|
|
25
|
+
Download
|
|
26
|
+
} from 'lucide-react';
|
|
27
|
+
import { Card } from '../../components/ui/Card';
|
|
28
|
+
import { Button } from '../../components/ui/Button';
|
|
29
|
+
import { Badge } from '../../components/ui/Badge';
|
|
30
|
+
|
|
31
|
+
export function DeploymentLab() {
|
|
32
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
33
|
+
const [deployments, setDeployments] = useState([]);
|
|
34
|
+
const [availableModels, setAvailableModels] = useState([]);
|
|
35
|
+
const [loading, setLoading] = useState(true);
|
|
36
|
+
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
37
|
+
const [selectedDeployment, setSelectedDeployment] = useState(null);
|
|
38
|
+
const [visibleTokens, setVisibleTokens] = useState(new Set());
|
|
39
|
+
const [modelSearch, setModelSearch] = useState('');
|
|
40
|
+
const [showModelDropdown, setShowModelDropdown] = useState(false);
|
|
41
|
+
const [deploymentLogs, setDeploymentLogs] = useState([]);
|
|
42
|
+
const [logsLoading, setLogsLoading] = useState(false);
|
|
43
|
+
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
|
44
|
+
|
|
45
|
+
// Dependency installation state
|
|
46
|
+
const [showDependencies, setShowDependencies] = useState(false);
|
|
47
|
+
const [installedDeps, setInstalledDeps] = useState({});
|
|
48
|
+
const [installingDeps, setInstallingDeps] = useState(new Set());
|
|
49
|
+
|
|
50
|
+
// Form state
|
|
51
|
+
const [newDeployment, setNewDeployment] = useState({
|
|
52
|
+
name: '',
|
|
53
|
+
model_artifact_id: '',
|
|
54
|
+
model_version: null,
|
|
55
|
+
port: null,
|
|
56
|
+
config: {
|
|
57
|
+
rate_limit: 100,
|
|
58
|
+
timeout_seconds: 30,
|
|
59
|
+
max_batch_size: 1,
|
|
60
|
+
enable_cors: true,
|
|
61
|
+
ttl_seconds: null // Auto-destroy after N seconds (null = never)
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
fetchDeployments();
|
|
67
|
+
fetchAvailableModels();
|
|
68
|
+
|
|
69
|
+
// Auto-refresh deployment status every 5 seconds for status updates and TTL countdown
|
|
70
|
+
const refreshInterval = setInterval(() => {
|
|
71
|
+
fetchDeployments();
|
|
72
|
+
}, 5000);
|
|
73
|
+
|
|
74
|
+
return () => clearInterval(refreshInterval);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Handle URL parameters for deploy action from Assets page
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const deployId = searchParams.get('deploy');
|
|
80
|
+
const modelName = searchParams.get('name');
|
|
81
|
+
|
|
82
|
+
if (deployId) {
|
|
83
|
+
// Pre-populate form with model info
|
|
84
|
+
setNewDeployment(prev => ({
|
|
85
|
+
...prev,
|
|
86
|
+
model_artifact_id: deployId,
|
|
87
|
+
name: modelName ? `${modelName}-api` : ''
|
|
88
|
+
}));
|
|
89
|
+
setShowCreateModal(true);
|
|
90
|
+
// Clear the URL params
|
|
91
|
+
setSearchParams({});
|
|
92
|
+
}
|
|
93
|
+
}, [searchParams, setSearchParams]);
|
|
94
|
+
|
|
95
|
+
const fetchDeployments = async () => {
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch('/api/deployments/');
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
setDeployments(Array.isArray(data) ? data : []);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('Failed to fetch deployments:', error);
|
|
102
|
+
} finally {
|
|
103
|
+
setLoading(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const fetchAvailableModels = async () => {
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetch('/api/deployments/available-models');
|
|
110
|
+
const data = await response.json();
|
|
111
|
+
setAvailableModels(Array.isArray(data) ? data : []);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error('Failed to fetch models:', error);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const fetchDependencyStatus = async () => {
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch('/api/deployments/dependencies/status');
|
|
120
|
+
const data = await response.json();
|
|
121
|
+
setInstalledDeps(data.installed || {});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error('Failed to fetch dependency status:', error);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const installDependency = async (framework) => {
|
|
128
|
+
setInstallingDeps(prev => new Set([...prev, framework]));
|
|
129
|
+
try {
|
|
130
|
+
const response = await fetch('/api/deployments/dependencies/install', {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json' },
|
|
133
|
+
body: JSON.stringify({ frameworks: [framework] })
|
|
134
|
+
});
|
|
135
|
+
if (response.ok) {
|
|
136
|
+
// Wait a bit for installation to start, then refresh status
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
fetchDependencyStatus();
|
|
139
|
+
setInstallingDeps(prev => {
|
|
140
|
+
const next = new Set(prev);
|
|
141
|
+
next.delete(framework);
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
}, 3000);
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error('Failed to install dependency:', error);
|
|
148
|
+
setInstallingDeps(prev => {
|
|
149
|
+
const next = new Set(prev);
|
|
150
|
+
next.delete(framework);
|
|
151
|
+
return next;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const createDeployment = async (e) => {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch('/api/deployments/', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: JSON.stringify(newDeployment)
|
|
163
|
+
});
|
|
164
|
+
if (response.ok) {
|
|
165
|
+
setShowCreateModal(false);
|
|
166
|
+
setNewDeployment({
|
|
167
|
+
name: '',
|
|
168
|
+
model_artifact_id: '',
|
|
169
|
+
model_version: null,
|
|
170
|
+
port: null,
|
|
171
|
+
config: { rate_limit: 100, timeout_seconds: 30, max_batch_size: 1, enable_cors: true }
|
|
172
|
+
});
|
|
173
|
+
fetchDeployments();
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('Failed to create deployment:', error);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const toggleDeployment = async (id, action) => {
|
|
181
|
+
try {
|
|
182
|
+
await fetch(`/api/deployments/${id}/${action}`, { method: 'POST' });
|
|
183
|
+
fetchDeployments();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error(`Failed to ${action} deployment:`, error);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const deleteDeployment = async (id) => {
|
|
190
|
+
try {
|
|
191
|
+
await fetch(`/api/deployments/${id}`, { method: 'DELETE' });
|
|
192
|
+
setDeleteConfirmId(null);
|
|
193
|
+
fetchDeployments();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.error('Failed to delete deployment:', error);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const copyToClipboard = (text) => {
|
|
200
|
+
navigator.clipboard.writeText(text);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const toggleTokenVisibility = (id) => {
|
|
204
|
+
setVisibleTokens(prev => {
|
|
205
|
+
const newSet = new Set(prev);
|
|
206
|
+
if (newSet.has(id)) newSet.delete(id);
|
|
207
|
+
else newSet.add(id);
|
|
208
|
+
return newSet;
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const maskToken = (token) => {
|
|
213
|
+
if (!token) return '••••••••••••••••';
|
|
214
|
+
return `${token.substring(0, 8)}••••••••${token.substring(token.length - 4)}`;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const getStatusColor = (status) => {
|
|
218
|
+
switch (status) {
|
|
219
|
+
case 'running': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400';
|
|
220
|
+
case 'pending':
|
|
221
|
+
case 'starting': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400';
|
|
222
|
+
case 'stopped': return 'bg-slate-100 text-slate-700 dark:bg-slate-900/20 dark:text-slate-400';
|
|
223
|
+
case 'error': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400';
|
|
224
|
+
default: return 'bg-slate-100 text-slate-700';
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const getStatusIcon = (status) => {
|
|
229
|
+
switch (status) {
|
|
230
|
+
case 'running': return <CheckCircle2 size={14} />;
|
|
231
|
+
case 'pending':
|
|
232
|
+
case 'starting': return <Loader2 size={14} className="animate-spin" />;
|
|
233
|
+
case 'stopped': return <Square size={14} />;
|
|
234
|
+
case 'error': return <AlertCircle size={14} />;
|
|
235
|
+
default: return <Clock size={14} />;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Fetch deployment logs
|
|
240
|
+
const fetchDeploymentLogs = async (deployment) => {
|
|
241
|
+
setSelectedDeployment(deployment);
|
|
242
|
+
setLogsLoading(true);
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch(`/api/deployments/${deployment.id}/logs`);
|
|
245
|
+
if (response.ok) {
|
|
246
|
+
const data = await response.json();
|
|
247
|
+
setDeploymentLogs(data.logs || []);
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.error('Failed to fetch logs:', error);
|
|
251
|
+
setDeploymentLogs([{ timestamp: new Date().toISOString(), level: 'ERROR', message: 'Failed to fetch logs' }]);
|
|
252
|
+
} finally {
|
|
253
|
+
setLogsLoading(false);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Calculate TTL countdown
|
|
258
|
+
const getTimeRemaining = (expiresAt) => {
|
|
259
|
+
if (!expiresAt) return null;
|
|
260
|
+
const diff = new Date(expiresAt) - new Date();
|
|
261
|
+
if (diff <= 0) return 'Expired';
|
|
262
|
+
const minutes = Math.floor(diff / 60000);
|
|
263
|
+
const seconds = Math.floor((diff % 60000) / 1000);
|
|
264
|
+
if (minutes >= 60) {
|
|
265
|
+
const hours = Math.floor(minutes / 60);
|
|
266
|
+
return `${hours}h ${minutes % 60}m`;
|
|
267
|
+
}
|
|
268
|
+
return `${minutes}m ${seconds}s`;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if (loading) {
|
|
273
|
+
return (
|
|
274
|
+
<div className="flex items-center justify-center min-h-screen">
|
|
275
|
+
<Loader2 className="w-12 h-12 animate-spin text-primary-600" />
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
|
282
|
+
{/* Header */}
|
|
283
|
+
<div className="flex items-center justify-between">
|
|
284
|
+
<div>
|
|
285
|
+
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
|
286
|
+
<div className="p-3 bg-gradient-to-br from-orange-500 to-red-500 rounded-xl text-white">
|
|
287
|
+
<Rocket size={28} />
|
|
288
|
+
</div>
|
|
289
|
+
Deployment Lab
|
|
290
|
+
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 text-xs font-medium">
|
|
291
|
+
Experimental
|
|
292
|
+
</Badge>
|
|
293
|
+
</h1>
|
|
294
|
+
<p className="text-slate-500 dark:text-slate-400 mt-2">
|
|
295
|
+
Deploy models as API endpoints (TFServing for Keras models coming soon)
|
|
296
|
+
</p>
|
|
297
|
+
</div>
|
|
298
|
+
<div className="flex gap-3">
|
|
299
|
+
<Button
|
|
300
|
+
onClick={() => {
|
|
301
|
+
setShowDependencies(!showDependencies);
|
|
302
|
+
if (!showDependencies) fetchDependencyStatus();
|
|
303
|
+
}}
|
|
304
|
+
variant="ghost"
|
|
305
|
+
className="flex items-center gap-2"
|
|
306
|
+
>
|
|
307
|
+
<Package size={16} />
|
|
308
|
+
Dependencies
|
|
309
|
+
</Button>
|
|
310
|
+
<Button
|
|
311
|
+
onClick={fetchDeployments}
|
|
312
|
+
variant="ghost"
|
|
313
|
+
className="flex items-center gap-2"
|
|
314
|
+
>
|
|
315
|
+
<RefreshCw size={16} />
|
|
316
|
+
Refresh
|
|
317
|
+
</Button>
|
|
318
|
+
<Button
|
|
319
|
+
onClick={() => setShowCreateModal(true)}
|
|
320
|
+
className="flex items-center gap-2 bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600"
|
|
321
|
+
>
|
|
322
|
+
<Plus size={16} />
|
|
323
|
+
New Deployment
|
|
324
|
+
</Button>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{/* ML Dependencies Panel */}
|
|
329
|
+
{showDependencies && (
|
|
330
|
+
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-200 dark:border-blue-800">
|
|
331
|
+
<div className="p-4">
|
|
332
|
+
<div className="flex items-center justify-between mb-4">
|
|
333
|
+
<div className="flex items-center gap-2">
|
|
334
|
+
<Package className="text-blue-500" size={20} />
|
|
335
|
+
<h3 className="font-semibold text-slate-900 dark:text-white">ML Framework Dependencies</h3>
|
|
336
|
+
</div>
|
|
337
|
+
<Button variant="ghost" size="sm" onClick={fetchDependencyStatus}>
|
|
338
|
+
<RefreshCw size={14} />
|
|
339
|
+
</Button>
|
|
340
|
+
</div>
|
|
341
|
+
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">
|
|
342
|
+
Install ML frameworks on the server to enable model predictions
|
|
343
|
+
</p>
|
|
344
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
345
|
+
{['keras', 'tensorflow', 'pytorch', 'sklearn', 'xgboost', 'onnx'].map(framework => (
|
|
346
|
+
<div key={framework} className="flex items-center justify-between p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
|
|
347
|
+
<span className="font-medium capitalize">{framework}</span>
|
|
348
|
+
{installedDeps[framework] ? (
|
|
349
|
+
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
|
350
|
+
<CheckCircle2 size={12} className="mr-1" />
|
|
351
|
+
Installed
|
|
352
|
+
</Badge>
|
|
353
|
+
) : installingDeps.has(framework) ? (
|
|
354
|
+
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
|
355
|
+
<Loader2 size={12} className="mr-1 animate-spin" />
|
|
356
|
+
Installing...
|
|
357
|
+
</Badge>
|
|
358
|
+
) : (
|
|
359
|
+
<Button
|
|
360
|
+
size="sm"
|
|
361
|
+
variant="ghost"
|
|
362
|
+
onClick={() => installDependency(framework)}
|
|
363
|
+
className="text-blue-600 hover:bg-blue-50"
|
|
364
|
+
>
|
|
365
|
+
<Download size={14} className="mr-1" />
|
|
366
|
+
Install
|
|
367
|
+
</Button>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
))}
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</Card>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
{/* Deployments Grid */}
|
|
377
|
+
{deployments.length === 0 ? (
|
|
378
|
+
<Card className="text-center py-16 bg-slate-50 dark:bg-slate-800/30">
|
|
379
|
+
<div className="mx-auto w-20 h-20 bg-gradient-to-br from-orange-100 to-red-100 dark:from-orange-900/20 dark:to-red-900/20 rounded-2xl flex items-center justify-center mb-6">
|
|
380
|
+
<Rocket className="text-orange-500" size={32} />
|
|
381
|
+
</div>
|
|
382
|
+
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">No Deployments Yet</h3>
|
|
383
|
+
<p className="text-slate-500 max-w-md mx-auto mb-6">
|
|
384
|
+
Deploy your first model as an API endpoint to start testing predictions
|
|
385
|
+
</p>
|
|
386
|
+
<Button
|
|
387
|
+
onClick={() => setShowCreateModal(true)}
|
|
388
|
+
className="bg-gradient-to-r from-orange-500 to-red-500"
|
|
389
|
+
>
|
|
390
|
+
<Plus size={16} className="mr-2" />
|
|
391
|
+
Create Your First Deployment
|
|
392
|
+
</Button>
|
|
393
|
+
</Card>
|
|
394
|
+
) : (
|
|
395
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
396
|
+
{deployments.map((deployment) => (
|
|
397
|
+
<Card key={deployment.id} className="hover:shadow-lg transition-all duration-200">
|
|
398
|
+
<div className="flex items-start justify-between mb-4">
|
|
399
|
+
<div className="flex items-center gap-3">
|
|
400
|
+
<div className={`p-2 rounded-lg ${deployment.status === 'running' ? 'bg-emerald-100 dark:bg-emerald-900/20' : 'bg-slate-100 dark:bg-slate-800'}`}>
|
|
401
|
+
<Rocket className={deployment.status === 'running' ? 'text-emerald-600' : 'text-slate-500'} size={20} />
|
|
402
|
+
</div>
|
|
403
|
+
<div>
|
|
404
|
+
<h3 className="font-semibold text-slate-900 dark:text-white">{deployment.name}</h3>
|
|
405
|
+
<p className="text-xs text-slate-500">ID: {deployment.id.substring(0, 8)}...</p>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
<Badge className={getStatusColor(deployment.status)}>
|
|
409
|
+
<span className="flex items-center gap-1">
|
|
410
|
+
{getStatusIcon(deployment.status)}
|
|
411
|
+
{deployment.status}
|
|
412
|
+
</span>
|
|
413
|
+
</Badge>
|
|
414
|
+
</div>
|
|
415
|
+
|
|
416
|
+
{/* Endpoint URL */}
|
|
417
|
+
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 mb-4">
|
|
418
|
+
<div className="flex items-center justify-between mb-1">
|
|
419
|
+
<span className="text-xs font-medium text-slate-500">Endpoint</span>
|
|
420
|
+
<button
|
|
421
|
+
onClick={() => copyToClipboard(deployment.endpoint_url + '/predict')}
|
|
422
|
+
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
|
|
423
|
+
>
|
|
424
|
+
<Copy size={12} className="text-slate-500" />
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
<code className="text-sm text-primary-600 dark:text-primary-400">
|
|
428
|
+
{deployment.endpoint_url}/predict
|
|
429
|
+
</code>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
{/* API Token */}
|
|
433
|
+
<div className="bg-amber-50 dark:bg-amber-900/10 rounded-lg p-3 mb-4 border border-amber-200 dark:border-amber-800">
|
|
434
|
+
<div className="flex items-center justify-between mb-1">
|
|
435
|
+
<span className="text-xs font-medium text-amber-700 dark:text-amber-400 flex items-center gap-1">
|
|
436
|
+
<Shield size={12} />
|
|
437
|
+
API Token
|
|
438
|
+
</span>
|
|
439
|
+
<div className="flex gap-1">
|
|
440
|
+
<button
|
|
441
|
+
onClick={() => toggleTokenVisibility(deployment.id)}
|
|
442
|
+
className="p-1 hover:bg-amber-200 dark:hover:bg-amber-800 rounded"
|
|
443
|
+
>
|
|
444
|
+
{visibleTokens.has(deployment.id) ? <EyeOff size={12} /> : <Eye size={12} />}
|
|
445
|
+
</button>
|
|
446
|
+
<button
|
|
447
|
+
onClick={() => copyToClipboard(deployment.api_token)}
|
|
448
|
+
className="p-1 hover:bg-amber-200 dark:hover:bg-amber-800 rounded"
|
|
449
|
+
>
|
|
450
|
+
<Copy size={12} />
|
|
451
|
+
</button>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
<code className="text-xs text-amber-800 dark:text-amber-300 font-mono">
|
|
455
|
+
{visibleTokens.has(deployment.id) ? deployment.api_token : maskToken(deployment.api_token)}
|
|
456
|
+
</code>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
{/* TTL Countdown & Health Status */}
|
|
460
|
+
{(deployment.expires_at || deployment.status === 'running') && (
|
|
461
|
+
<div className="flex items-center gap-3 mb-4">
|
|
462
|
+
{deployment.expires_at && (
|
|
463
|
+
<div className="flex items-center gap-1.5 px-2 py-1 bg-purple-100 dark:bg-purple-900/20 rounded text-xs">
|
|
464
|
+
<Clock size={12} className="text-purple-600" />
|
|
465
|
+
<span className="font-medium text-purple-700 dark:text-purple-400">
|
|
466
|
+
TTL: {getTimeRemaining(deployment.expires_at)}
|
|
467
|
+
</span>
|
|
468
|
+
</div>
|
|
469
|
+
)}
|
|
470
|
+
{deployment.status === 'running' && (
|
|
471
|
+
<div className="flex items-center gap-1.5 px-2 py-1 bg-emerald-100 dark:bg-emerald-900/20 rounded text-xs">
|
|
472
|
+
<CheckCircle2 size={12} className="text-emerald-600" />
|
|
473
|
+
<span className="font-medium text-emerald-700 dark:text-emerald-400">Healthy</span>
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
{deployment.status === 'error' && deployment.error_message && (
|
|
477
|
+
<div className="flex items-center gap-1.5 px-2 py-1 bg-rose-100 dark:bg-rose-900/20 rounded text-xs flex-1">
|
|
478
|
+
<AlertCircle size={12} className="text-rose-600" />
|
|
479
|
+
<span className="font-medium text-rose-700 dark:text-rose-400 truncate">{deployment.error_message}</span>
|
|
480
|
+
</div>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
|
|
485
|
+
{/* Actions */}
|
|
486
|
+
<div className="flex gap-2">
|
|
487
|
+
{deployment.status === 'running' ? (
|
|
488
|
+
<Button
|
|
489
|
+
onClick={() => toggleDeployment(deployment.id, 'stop')}
|
|
490
|
+
variant="ghost"
|
|
491
|
+
className="flex-1 text-amber-600 hover:bg-amber-50"
|
|
492
|
+
>
|
|
493
|
+
<Square size={14} className="mr-1" />
|
|
494
|
+
Stop
|
|
495
|
+
</Button>
|
|
496
|
+
) : deployment.status === 'stopped' ? (
|
|
497
|
+
<Button
|
|
498
|
+
onClick={() => toggleDeployment(deployment.id, 'start')}
|
|
499
|
+
variant="ghost"
|
|
500
|
+
className="flex-1 text-emerald-600 hover:bg-emerald-50"
|
|
501
|
+
>
|
|
502
|
+
<Play size={14} className="mr-1" />
|
|
503
|
+
Start
|
|
504
|
+
</Button>
|
|
505
|
+
) : null}
|
|
506
|
+
<Button
|
|
507
|
+
onClick={() => fetchDeploymentLogs(deployment)}
|
|
508
|
+
variant="ghost"
|
|
509
|
+
className="flex-1"
|
|
510
|
+
>
|
|
511
|
+
<Terminal size={14} className="mr-1" />
|
|
512
|
+
Logs
|
|
513
|
+
</Button>
|
|
514
|
+
<Button
|
|
515
|
+
onClick={() => setDeleteConfirmId(deployment.id)}
|
|
516
|
+
variant="ghost"
|
|
517
|
+
className="text-rose-600 hover:bg-rose-50"
|
|
518
|
+
>
|
|
519
|
+
<Trash2 size={14} />
|
|
520
|
+
</Button>
|
|
521
|
+
</div>
|
|
522
|
+
</Card>
|
|
523
|
+
))}
|
|
524
|
+
</div>
|
|
525
|
+
)}
|
|
526
|
+
|
|
527
|
+
{/* Create Deployment Modal */}
|
|
528
|
+
{showCreateModal && (
|
|
529
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
530
|
+
<Card className="max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
|
531
|
+
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-6 flex items-center gap-2">
|
|
532
|
+
<Rocket className="text-orange-500" size={24} />
|
|
533
|
+
New Deployment
|
|
534
|
+
</h2>
|
|
535
|
+
|
|
536
|
+
<form onSubmit={createDeployment} className="space-y-4">
|
|
537
|
+
<div>
|
|
538
|
+
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
539
|
+
Deployment Name
|
|
540
|
+
</label>
|
|
541
|
+
<input
|
|
542
|
+
type="text"
|
|
543
|
+
value={newDeployment.name}
|
|
544
|
+
onChange={(e) => setNewDeployment({ ...newDeployment, name: e.target.value })}
|
|
545
|
+
placeholder="e.g., My Model API"
|
|
546
|
+
className="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-orange-500 bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
|
|
547
|
+
required
|
|
548
|
+
/>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
<div>
|
|
552
|
+
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
553
|
+
Model {availableModels.length > 0 && <span className="text-slate-400">({availableModels.length} available)</span>}
|
|
554
|
+
</label>
|
|
555
|
+
{/* Searchable model selector */}
|
|
556
|
+
<div className="relative">
|
|
557
|
+
<input
|
|
558
|
+
type="text"
|
|
559
|
+
placeholder="Search models..."
|
|
560
|
+
value={modelSearch}
|
|
561
|
+
onChange={(e) => setModelSearch(e.target.value)}
|
|
562
|
+
onFocus={() => setShowModelDropdown(true)}
|
|
563
|
+
className="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-orange-500 bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
|
|
564
|
+
/>
|
|
565
|
+
{newDeployment.model_artifact_id && (
|
|
566
|
+
<div className="mt-1 px-2 py-1 bg-orange-100 dark:bg-orange-900/30 rounded text-sm text-orange-700 dark:text-orange-300 flex items-center justify-between">
|
|
567
|
+
<span>Selected: {availableModels.find(m => m.artifact_id === newDeployment.model_artifact_id)?.name || newDeployment.model_artifact_id}</span>
|
|
568
|
+
<button type="button" onClick={() => setNewDeployment({ ...newDeployment, model_artifact_id: '' })} className="text-orange-500 hover:text-orange-700">×</button>
|
|
569
|
+
</div>
|
|
570
|
+
)}
|
|
571
|
+
{showModelDropdown && (
|
|
572
|
+
<div className="absolute z-50 w-full mt-1 max-h-60 overflow-y-auto bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg shadow-lg">
|
|
573
|
+
{availableModels
|
|
574
|
+
.filter(m =>
|
|
575
|
+
m.name?.toLowerCase().includes(modelSearch.toLowerCase()) ||
|
|
576
|
+
m.type?.toLowerCase().includes(modelSearch.toLowerCase()) ||
|
|
577
|
+
m.project?.toLowerCase().includes(modelSearch.toLowerCase())
|
|
578
|
+
)
|
|
579
|
+
.slice(0, 20)
|
|
580
|
+
.map((model) => (
|
|
581
|
+
<button
|
|
582
|
+
key={model.artifact_id}
|
|
583
|
+
type="button"
|
|
584
|
+
onClick={() => {
|
|
585
|
+
setNewDeployment({ ...newDeployment, model_artifact_id: model.artifact_id });
|
|
586
|
+
setModelSearch('');
|
|
587
|
+
setShowModelDropdown(false);
|
|
588
|
+
}}
|
|
589
|
+
className="w-full text-left px-4 py-2 hover:bg-slate-100 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-700 last:border-0"
|
|
590
|
+
>
|
|
591
|
+
<div className="font-medium text-slate-900 dark:text-white flex items-center gap-2">
|
|
592
|
+
{model.name}
|
|
593
|
+
{model.file_exists ? (
|
|
594
|
+
<span className="px-1 py-0.5 text-xs bg-emerald-100 text-emerald-700 rounded">Ready</span>
|
|
595
|
+
) : model.has_file ? (
|
|
596
|
+
<span className="px-1 py-0.5 text-xs bg-amber-100 text-amber-700 rounded">Missing</span>
|
|
597
|
+
) : (
|
|
598
|
+
<span className="px-1 py-0.5 text-xs bg-rose-100 text-rose-700 rounded">No File</span>
|
|
599
|
+
)}
|
|
600
|
+
</div>
|
|
601
|
+
<div className="text-xs text-slate-500 flex gap-2">
|
|
602
|
+
<span className="px-1 bg-slate-200 dark:bg-slate-600 rounded">{model.type}</span>
|
|
603
|
+
{model.project && <span>{model.project}</span>}
|
|
604
|
+
</div>
|
|
605
|
+
</button>
|
|
606
|
+
))}
|
|
607
|
+
{availableModels.filter(m => m.name?.toLowerCase().includes(modelSearch.toLowerCase())).length === 0 && (
|
|
608
|
+
<div className="px-4 py-3 text-slate-500 text-center">No models found</div>
|
|
609
|
+
)}
|
|
610
|
+
</div>
|
|
611
|
+
)}
|
|
612
|
+
</div>
|
|
613
|
+
{availableModels.length === 0 && (
|
|
614
|
+
<p className="text-xs text-slate-500 mt-1">
|
|
615
|
+
No models available. Train a model first!
|
|
616
|
+
</p>
|
|
617
|
+
)}
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
<div>
|
|
621
|
+
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
622
|
+
Port (optional)
|
|
623
|
+
</label>
|
|
624
|
+
<input
|
|
625
|
+
type="number"
|
|
626
|
+
value={newDeployment.port || ''}
|
|
627
|
+
onChange={(e) => setNewDeployment({ ...newDeployment, port: e.target.value ? parseInt(e.target.value) : null })}
|
|
628
|
+
placeholder="Auto-assigned if empty"
|
|
629
|
+
className="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-orange-500 bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
|
|
630
|
+
/>
|
|
631
|
+
</div>
|
|
632
|
+
|
|
633
|
+
<div>
|
|
634
|
+
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
635
|
+
Auto-Destroy (TTL)
|
|
636
|
+
</label>
|
|
637
|
+
<select
|
|
638
|
+
value={newDeployment.config.ttl_seconds || ''}
|
|
639
|
+
onChange={(e) => setNewDeployment({
|
|
640
|
+
...newDeployment,
|
|
641
|
+
config: {
|
|
642
|
+
...newDeployment.config,
|
|
643
|
+
ttl_seconds: e.target.value ? parseInt(e.target.value) : null
|
|
644
|
+
}
|
|
645
|
+
})}
|
|
646
|
+
className="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-orange-500 bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
|
|
647
|
+
>
|
|
648
|
+
<option value="">Never (keep running)</option>
|
|
649
|
+
<option value="300">5 minutes</option>
|
|
650
|
+
<option value="900">15 minutes</option>
|
|
651
|
+
<option value="1800">30 minutes</option>
|
|
652
|
+
<option value="3600">1 hour</option>
|
|
653
|
+
<option value="7200">2 hours</option>
|
|
654
|
+
</select>
|
|
655
|
+
<p className="text-xs text-slate-500 mt-1">
|
|
656
|
+
Automatically stop deployment after selected time
|
|
657
|
+
</p>
|
|
658
|
+
</div>
|
|
659
|
+
|
|
660
|
+
<div className="pt-4 flex gap-3 justify-end">
|
|
661
|
+
<Button
|
|
662
|
+
type="button"
|
|
663
|
+
variant="ghost"
|
|
664
|
+
onClick={() => setShowCreateModal(false)}
|
|
665
|
+
>
|
|
666
|
+
Cancel
|
|
667
|
+
</Button>
|
|
668
|
+
<Button
|
|
669
|
+
type="submit"
|
|
670
|
+
className="bg-gradient-to-r from-orange-500 to-red-500"
|
|
671
|
+
>
|
|
672
|
+
<Rocket size={16} className="mr-2" />
|
|
673
|
+
Deploy
|
|
674
|
+
</Button>
|
|
675
|
+
</div>
|
|
676
|
+
</form>
|
|
677
|
+
</Card>
|
|
678
|
+
</div>
|
|
679
|
+
)}
|
|
680
|
+
|
|
681
|
+
{/* Logs Modal */}
|
|
682
|
+
{selectedDeployment && (
|
|
683
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
684
|
+
<Card className="max-w-2xl w-full max-h-[80vh] flex flex-col">
|
|
685
|
+
<div className="flex items-center justify-between mb-4">
|
|
686
|
+
<h2 className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
|
687
|
+
<Terminal className="text-orange-500" size={20} />
|
|
688
|
+
Logs: {selectedDeployment.name}
|
|
689
|
+
</h2>
|
|
690
|
+
<div className="flex items-center gap-2">
|
|
691
|
+
<Badge className={getStatusColor(selectedDeployment.status)}>
|
|
692
|
+
{selectedDeployment.status}
|
|
693
|
+
</Badge>
|
|
694
|
+
<button
|
|
695
|
+
onClick={() => { setSelectedDeployment(null); setDeploymentLogs([]); }}
|
|
696
|
+
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
|
|
697
|
+
>
|
|
698
|
+
<X size={20} />
|
|
699
|
+
</button>
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
|
|
703
|
+
<div className="flex-1 overflow-y-auto bg-slate-900 rounded-lg p-4 font-mono text-sm">
|
|
704
|
+
{logsLoading ? (
|
|
705
|
+
<div className="flex items-center justify-center py-8">
|
|
706
|
+
<Loader2 className="animate-spin text-slate-400" size={24} />
|
|
707
|
+
</div>
|
|
708
|
+
) : deploymentLogs.length === 0 ? (
|
|
709
|
+
<div className="text-slate-500 text-center py-8">No logs available</div>
|
|
710
|
+
) : (
|
|
711
|
+
deploymentLogs.map((log, index) => (
|
|
712
|
+
<div key={index} className="mb-1 flex gap-2">
|
|
713
|
+
<span className="text-slate-500 shrink-0">
|
|
714
|
+
{new Date(log.timestamp).toLocaleTimeString()}
|
|
715
|
+
</span>
|
|
716
|
+
<span className={`shrink-0 px-1 rounded text-xs font-bold ${log.level === 'ERROR' ? 'bg-rose-900 text-rose-400' :
|
|
717
|
+
log.level === 'WARN' ? 'bg-amber-900 text-amber-400' :
|
|
718
|
+
'bg-emerald-900 text-emerald-400'
|
|
719
|
+
}`}>
|
|
720
|
+
{log.level}
|
|
721
|
+
</span>
|
|
722
|
+
<span className="text-slate-300">{log.message}</span>
|
|
723
|
+
</div>
|
|
724
|
+
))
|
|
725
|
+
)}
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<div className="mt-4 flex justify-between">
|
|
729
|
+
<Button
|
|
730
|
+
onClick={() => fetchDeploymentLogs(selectedDeployment)}
|
|
731
|
+
variant="ghost"
|
|
732
|
+
className="flex items-center gap-2"
|
|
733
|
+
>
|
|
734
|
+
<RefreshCw size={14} />
|
|
735
|
+
Refresh Logs
|
|
736
|
+
</Button>
|
|
737
|
+
<Button
|
|
738
|
+
onClick={() => { setSelectedDeployment(null); setDeploymentLogs([]); }}
|
|
739
|
+
variant="ghost"
|
|
740
|
+
>
|
|
741
|
+
Close
|
|
742
|
+
</Button>
|
|
743
|
+
</div>
|
|
744
|
+
</Card>
|
|
745
|
+
</div>
|
|
746
|
+
)}
|
|
747
|
+
|
|
748
|
+
{/* Delete Confirmation Modal */}
|
|
749
|
+
{deleteConfirmId && (
|
|
750
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
751
|
+
<Card className="max-w-sm w-full">
|
|
752
|
+
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
|
|
753
|
+
<AlertCircle className="text-rose-500" size={24} />
|
|
754
|
+
Delete Deployment
|
|
755
|
+
</h2>
|
|
756
|
+
<p className="text-slate-600 dark:text-slate-400 mb-6">
|
|
757
|
+
Are you sure you want to delete this deployment? This action cannot be undone.
|
|
758
|
+
</p>
|
|
759
|
+
<div className="flex gap-3 justify-end">
|
|
760
|
+
<Button
|
|
761
|
+
variant="ghost"
|
|
762
|
+
onClick={() => setDeleteConfirmId(null)}
|
|
763
|
+
>
|
|
764
|
+
Cancel
|
|
765
|
+
</Button>
|
|
766
|
+
<Button
|
|
767
|
+
onClick={() => deleteDeployment(deleteConfirmId)}
|
|
768
|
+
className="bg-rose-500 hover:bg-rose-600 text-white"
|
|
769
|
+
>
|
|
770
|
+
<Trash2 size={14} className="mr-2" />
|
|
771
|
+
Delete
|
|
772
|
+
</Button>
|
|
773
|
+
</div>
|
|
774
|
+
</Card>
|
|
775
|
+
</div>
|
|
776
|
+
)}
|
|
777
|
+
|
|
778
|
+
{/* Footer Branding */}
|
|
779
|
+
<div className="text-center pt-8 pb-4 border-t border-slate-200 dark:border-slate-700">
|
|
780
|
+
<p className="text-xs text-slate-400 dark:text-slate-500">
|
|
781
|
+
Made with ❤️ by <span className="font-medium text-primary-500">UnicoLab</span>
|
|
782
|
+
</p>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
);
|
|
786
|
+
}
|