@lamalibre/lamaste-agent-ui 4.0.0
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.
- package/LICENSE.md +118 -0
- package/dist-web/assets/index-Cl7aUHaU.css +1 -0
- package/dist-web/assets/index-D1W7KjDX.js +8 -0
- package/dist-web/index.html +13 -0
- package/package.json +45 -0
- package/src/WebApp.jsx +74 -0
- package/src/components/Toast.jsx +55 -0
- package/src/context/AgentClientContext.jsx +88 -0
- package/src/index.js +19 -0
- package/src/lib/cn.js +6 -0
- package/src/lib/errorMessage.js +10 -0
- package/src/lib/web-agent-client.js +114 -0
- package/src/pages/Dashboard.jsx +175 -0
- package/src/pages/Logs.jsx +54 -0
- package/src/pages/Plugins.jsx +866 -0
- package/src/pages/Services.jsx +644 -0
- package/src/pages/Settings.jsx +409 -0
- package/src/pages/Tunnels.jsx +273 -0
- package/src/web-entry.jsx +31 -0
- package/src/web.css +3 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import {
|
|
4
|
+
Loader2,
|
|
5
|
+
RefreshCw,
|
|
6
|
+
Plus,
|
|
7
|
+
X,
|
|
8
|
+
Globe,
|
|
9
|
+
Brain,
|
|
10
|
+
Database,
|
|
11
|
+
Container,
|
|
12
|
+
Code,
|
|
13
|
+
Film,
|
|
14
|
+
BarChart3,
|
|
15
|
+
Wrench,
|
|
16
|
+
Trash2,
|
|
17
|
+
} from 'lucide-react';
|
|
18
|
+
import { useAgentClient } from '../context/AgentClientContext.jsx';
|
|
19
|
+
|
|
20
|
+
const CATEGORIES = [
|
|
21
|
+
{ id: 'all', label: 'All', icon: null },
|
|
22
|
+
{ id: 'ai', label: 'AI', icon: Brain },
|
|
23
|
+
{ id: 'database', label: 'Database', icon: Database },
|
|
24
|
+
{ id: 'docker', label: 'Docker', icon: Container },
|
|
25
|
+
{ id: 'dev', label: 'Dev', icon: Code },
|
|
26
|
+
{ id: 'media', label: 'Media', icon: Film },
|
|
27
|
+
{ id: 'monitoring', label: 'Monitoring', icon: BarChart3 },
|
|
28
|
+
{ id: 'custom', label: 'Custom', icon: Wrench },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const CATEGORY_COLORS = {
|
|
32
|
+
ai: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
|
33
|
+
database: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
|
34
|
+
dev: 'text-green-400 bg-green-500/10 border-green-500/20',
|
|
35
|
+
media: 'text-pink-400 bg-pink-500/10 border-pink-500/20',
|
|
36
|
+
monitoring: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
|
37
|
+
custom: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const STATUS_BADGE = {
|
|
41
|
+
running: 'text-green-400 bg-green-500/10 border-green-500/20',
|
|
42
|
+
installed: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
|
43
|
+
not_found: 'text-zinc-500 bg-zinc-800 border-zinc-700',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const CATEGORY_DROPDOWN_OPTIONS = ['ai', 'database', 'dev', 'media', 'monitoring', 'custom'];
|
|
47
|
+
|
|
48
|
+
function CategoryIcon({ category, size = 14 }) {
|
|
49
|
+
const cat = CATEGORIES.find((c) => c.id === category);
|
|
50
|
+
if (!cat?.icon) return null;
|
|
51
|
+
const Icon = cat.icon;
|
|
52
|
+
return <Icon size={size} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function Services() {
|
|
56
|
+
const client = useAgentClient();
|
|
57
|
+
const queryClient = useQueryClient();
|
|
58
|
+
const [activeCategory, setActiveCategory] = useState('all');
|
|
59
|
+
const [showExposeModal, setShowExposeModal] = useState(null);
|
|
60
|
+
const [showCustomForm, setShowCustomForm] = useState(false);
|
|
61
|
+
const [exposeData, setExposeData] = useState({ subdomain: '', port: '', description: '' });
|
|
62
|
+
const [exposeError, setExposeError] = useState(null);
|
|
63
|
+
const [customData, setCustomData] = useState({
|
|
64
|
+
name: '',
|
|
65
|
+
port: '',
|
|
66
|
+
binary: '',
|
|
67
|
+
processName: '',
|
|
68
|
+
category: 'dev',
|
|
69
|
+
description: '',
|
|
70
|
+
});
|
|
71
|
+
const [customError, setCustomError] = useState(null);
|
|
72
|
+
const [removeConfirm, setRemoveConfirm] = useState(null);
|
|
73
|
+
const [removeError, setRemoveError] = useState(null);
|
|
74
|
+
|
|
75
|
+
const servicesQuery = useQuery({
|
|
76
|
+
queryKey: ['agent', 'services'],
|
|
77
|
+
queryFn: () => client.scanServices(),
|
|
78
|
+
refetchInterval: 15000,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const exposeMutation = useMutation({
|
|
82
|
+
mutationFn: async (data) => {
|
|
83
|
+
await client.createTunnel({
|
|
84
|
+
subdomain: data.subdomain,
|
|
85
|
+
port: parseInt(data.port, 10),
|
|
86
|
+
description: data.description || '',
|
|
87
|
+
});
|
|
88
|
+
await client.updateAgent();
|
|
89
|
+
},
|
|
90
|
+
onSuccess: () => {
|
|
91
|
+
setShowExposeModal(null);
|
|
92
|
+
setExposeError(null);
|
|
93
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'services'] });
|
|
94
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'tunnels'] });
|
|
95
|
+
},
|
|
96
|
+
onError: (err) => {
|
|
97
|
+
setExposeError(err?.message || String(err));
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const addCustomMutation = useMutation({
|
|
102
|
+
mutationFn: (data) =>
|
|
103
|
+
client.addCustomService({
|
|
104
|
+
name: data.name,
|
|
105
|
+
port: parseInt(data.port, 10),
|
|
106
|
+
binary: data.binary || undefined,
|
|
107
|
+
processName: data.processName || undefined,
|
|
108
|
+
category: data.category,
|
|
109
|
+
description: data.description || '',
|
|
110
|
+
}),
|
|
111
|
+
onSuccess: () => {
|
|
112
|
+
setShowCustomForm(false);
|
|
113
|
+
setCustomData({
|
|
114
|
+
name: '',
|
|
115
|
+
port: '',
|
|
116
|
+
binary: '',
|
|
117
|
+
processName: '',
|
|
118
|
+
category: 'dev',
|
|
119
|
+
description: '',
|
|
120
|
+
});
|
|
121
|
+
setCustomError(null);
|
|
122
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'services'] });
|
|
123
|
+
},
|
|
124
|
+
onError: (err) => {
|
|
125
|
+
setCustomError(err?.message || String(err));
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const removeCustomMutation = useMutation({
|
|
130
|
+
mutationFn: (id) => client.removeCustomService(id),
|
|
131
|
+
onSuccess: () => {
|
|
132
|
+
setRemoveConfirm(null);
|
|
133
|
+
setRemoveError(null);
|
|
134
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'services'] });
|
|
135
|
+
},
|
|
136
|
+
onError: (err) => {
|
|
137
|
+
setRemoveError(err?.message || String(err));
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const scanResult = servicesQuery.data;
|
|
142
|
+
const allServices = scanResult?.services || [];
|
|
143
|
+
const dockerContainers = scanResult?.dockerContainers || [];
|
|
144
|
+
|
|
145
|
+
const filteredServices =
|
|
146
|
+
activeCategory === 'all'
|
|
147
|
+
? allServices
|
|
148
|
+
: activeCategory === 'custom'
|
|
149
|
+
? allServices.filter((s) => s.source === 'custom')
|
|
150
|
+
: allServices.filter((s) => s.category === activeCategory);
|
|
151
|
+
|
|
152
|
+
const showDocker = activeCategory === 'all' || activeCategory === 'docker';
|
|
153
|
+
|
|
154
|
+
const openExposeModal = (id, port, description) => {
|
|
155
|
+
setExposeData({ subdomain: id, port: String(port), description });
|
|
156
|
+
setExposeError(null);
|
|
157
|
+
setShowExposeModal(id);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const handleExpose = (e) => {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
setExposeError(null);
|
|
163
|
+
const port = parseInt(exposeData.port, 10);
|
|
164
|
+
if (!exposeData.subdomain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(exposeData.subdomain)) {
|
|
165
|
+
setExposeError('Subdomain must be lowercase alphanumeric with optional hyphens');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (exposeData.subdomain.length > 63) {
|
|
169
|
+
setExposeError('Subdomain must be at most 63 characters');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
173
|
+
setExposeError('Port must be between 1024 and 65535');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
exposeMutation.mutate(exposeData);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const handleAddCustom = (e) => {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
setCustomError(null);
|
|
182
|
+
if (!customData.name.trim()) {
|
|
183
|
+
setCustomError('Name is required');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const port = parseInt(customData.port, 10);
|
|
187
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
188
|
+
setCustomError('Port must be between 1 and 65535');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
addCustomMutation.mutate(customData);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className="p-6">
|
|
196
|
+
{/* Header */}
|
|
197
|
+
<div className="flex items-center justify-between mb-6">
|
|
198
|
+
<h1 className="text-lg font-bold text-white">Services</h1>
|
|
199
|
+
<div className="flex items-center gap-2">
|
|
200
|
+
<button
|
|
201
|
+
onClick={() => queryClient.invalidateQueries({ queryKey: ['agent', 'services'] })}
|
|
202
|
+
disabled={servicesQuery.isFetching}
|
|
203
|
+
className="flex items-center gap-1.5 rounded bg-zinc-800 border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-700 disabled:opacity-50"
|
|
204
|
+
>
|
|
205
|
+
<RefreshCw size={14} className={servicesQuery.isFetching ? 'animate-spin' : ''} />
|
|
206
|
+
Scan
|
|
207
|
+
</button>
|
|
208
|
+
<button
|
|
209
|
+
onClick={() => setShowCustomForm(true)}
|
|
210
|
+
className="flex items-center gap-1.5 rounded bg-cyan-600 hover:bg-cyan-500 px-3 py-1.5 text-sm text-white"
|
|
211
|
+
>
|
|
212
|
+
<Plus size={14} />
|
|
213
|
+
Add Custom
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{/* Category filter */}
|
|
219
|
+
<div className="flex gap-1 mb-5 flex-wrap">
|
|
220
|
+
{CATEGORIES.map(({ id, label, icon: Icon }) => (
|
|
221
|
+
<button
|
|
222
|
+
key={id}
|
|
223
|
+
onClick={() => setActiveCategory(id)}
|
|
224
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
|
225
|
+
activeCategory === id
|
|
226
|
+
? 'bg-zinc-800 text-cyan-400 border border-cyan-500/30'
|
|
227
|
+
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200 hover:bg-zinc-800'
|
|
228
|
+
}`}
|
|
229
|
+
>
|
|
230
|
+
{Icon && <Icon size={12} />}
|
|
231
|
+
{label}
|
|
232
|
+
</button>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* Custom service form */}
|
|
237
|
+
{showCustomForm && (
|
|
238
|
+
<form
|
|
239
|
+
onSubmit={handleAddCustom}
|
|
240
|
+
className="bg-zinc-900 border border-zinc-800 rounded-lg p-5 mb-4"
|
|
241
|
+
>
|
|
242
|
+
<div className="flex items-center justify-between mb-4">
|
|
243
|
+
<h2 className="text-sm font-semibold text-white">Add Custom Service</h2>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => {
|
|
247
|
+
setShowCustomForm(false);
|
|
248
|
+
setCustomError(null);
|
|
249
|
+
}}
|
|
250
|
+
className="text-zinc-500 hover:text-zinc-300"
|
|
251
|
+
>
|
|
252
|
+
<X size={16} />
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
<div className="grid grid-cols-2 gap-3 mb-3">
|
|
256
|
+
<div>
|
|
257
|
+
<label className="text-xs text-zinc-500 block mb-1">Name</label>
|
|
258
|
+
<input
|
|
259
|
+
type="text"
|
|
260
|
+
placeholder="My Service"
|
|
261
|
+
value={customData.name}
|
|
262
|
+
onChange={(e) => setCustomData({ ...customData, name: e.target.value })}
|
|
263
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-cyan-500 focus:outline-none"
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
<div>
|
|
267
|
+
<label className="text-xs text-zinc-500 block mb-1">Port</label>
|
|
268
|
+
<input
|
|
269
|
+
type="number"
|
|
270
|
+
placeholder="3000"
|
|
271
|
+
min={1}
|
|
272
|
+
max={65535}
|
|
273
|
+
value={customData.port}
|
|
274
|
+
onChange={(e) => setCustomData({ ...customData, port: e.target.value })}
|
|
275
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-cyan-500 focus:outline-none"
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
<div className="grid grid-cols-2 gap-3 mb-3">
|
|
280
|
+
<div>
|
|
281
|
+
<label className="text-xs text-zinc-500 block mb-1">Binary (optional)</label>
|
|
282
|
+
<input
|
|
283
|
+
type="text"
|
|
284
|
+
placeholder="my-service"
|
|
285
|
+
value={customData.binary}
|
|
286
|
+
onChange={(e) => setCustomData({ ...customData, binary: e.target.value })}
|
|
287
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-cyan-500 focus:outline-none"
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
<div>
|
|
291
|
+
<label className="text-xs text-zinc-500 block mb-1">Process name (optional)</label>
|
|
292
|
+
<input
|
|
293
|
+
type="text"
|
|
294
|
+
placeholder="my-service"
|
|
295
|
+
value={customData.processName}
|
|
296
|
+
onChange={(e) => setCustomData({ ...customData, processName: e.target.value })}
|
|
297
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-cyan-500 focus:outline-none"
|
|
298
|
+
/>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
<div className="grid grid-cols-2 gap-3 mb-3">
|
|
302
|
+
<div>
|
|
303
|
+
<label className="text-xs text-zinc-500 block mb-1">Category</label>
|
|
304
|
+
<select
|
|
305
|
+
value={customData.category}
|
|
306
|
+
onChange={(e) => setCustomData({ ...customData, category: e.target.value })}
|
|
307
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
|
|
308
|
+
>
|
|
309
|
+
{CATEGORY_DROPDOWN_OPTIONS.map((cat) => (
|
|
310
|
+
<option key={cat} value={cat}>
|
|
311
|
+
{cat}
|
|
312
|
+
</option>
|
|
313
|
+
))}
|
|
314
|
+
</select>
|
|
315
|
+
</div>
|
|
316
|
+
<div>
|
|
317
|
+
<label className="text-xs text-zinc-500 block mb-1">Description</label>
|
|
318
|
+
<input
|
|
319
|
+
type="text"
|
|
320
|
+
placeholder="What this service does"
|
|
321
|
+
value={customData.description}
|
|
322
|
+
onChange={(e) => setCustomData({ ...customData, description: e.target.value })}
|
|
323
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-cyan-500 focus:outline-none"
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
{customError && <p className="text-red-400 text-xs mb-3">{customError}</p>}
|
|
328
|
+
<div className="flex gap-2">
|
|
329
|
+
<button
|
|
330
|
+
type="submit"
|
|
331
|
+
disabled={addCustomMutation.isPending}
|
|
332
|
+
className="flex items-center gap-2 rounded bg-cyan-600 hover:bg-cyan-500 px-4 py-2 text-sm text-white disabled:opacity-50"
|
|
333
|
+
>
|
|
334
|
+
{addCustomMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
|
335
|
+
Add Service
|
|
336
|
+
</button>
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
onClick={() => {
|
|
340
|
+
setShowCustomForm(false);
|
|
341
|
+
setCustomError(null);
|
|
342
|
+
}}
|
|
343
|
+
className="rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700"
|
|
344
|
+
>
|
|
345
|
+
Cancel
|
|
346
|
+
</button>
|
|
347
|
+
</div>
|
|
348
|
+
</form>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{/* Loading state */}
|
|
352
|
+
{servicesQuery.isLoading ? (
|
|
353
|
+
<div className="flex items-center gap-2 text-zinc-500">
|
|
354
|
+
<Loader2 size={14} className="animate-spin" />
|
|
355
|
+
Scanning services...
|
|
356
|
+
</div>
|
|
357
|
+
) : (
|
|
358
|
+
<>
|
|
359
|
+
{/* Service cards */}
|
|
360
|
+
{filteredServices.length > 0 && (
|
|
361
|
+
<div className="grid grid-cols-2 gap-4 mb-6">
|
|
362
|
+
{filteredServices.map((service) => (
|
|
363
|
+
<ServiceCard
|
|
364
|
+
key={service.id}
|
|
365
|
+
service={service}
|
|
366
|
+
onExpose={() =>
|
|
367
|
+
openExposeModal(
|
|
368
|
+
service.id,
|
|
369
|
+
service.detectedPort || service.defaultPort,
|
|
370
|
+
service.description,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
isCustom={service.source === 'custom'}
|
|
374
|
+
removeConfirm={removeConfirm}
|
|
375
|
+
onRemoveClick={() => setRemoveConfirm(service.id)}
|
|
376
|
+
onRemoveConfirm={() => removeCustomMutation.mutate(service.id)}
|
|
377
|
+
onRemoveCancel={() => {
|
|
378
|
+
setRemoveConfirm(null);
|
|
379
|
+
setRemoveError(null);
|
|
380
|
+
}}
|
|
381
|
+
removeIsPending={removeCustomMutation.isPending}
|
|
382
|
+
/>
|
|
383
|
+
))}
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{removeError && <p className="text-red-400 text-xs mb-3">{removeError}</p>}
|
|
388
|
+
|
|
389
|
+
{filteredServices.length === 0 && !showDocker && (
|
|
390
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-8 text-center mb-6">
|
|
391
|
+
<p className="text-zinc-400 text-sm">No services found in this category.</p>
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
|
|
395
|
+
{/* Docker section */}
|
|
396
|
+
{showDocker && dockerContainers.length > 0 && (
|
|
397
|
+
<>
|
|
398
|
+
<div className="flex items-center gap-2 mb-4">
|
|
399
|
+
<Container size={16} className="text-cyan-400" />
|
|
400
|
+
<h2 className="text-sm font-semibold text-white">Docker Containers</h2>
|
|
401
|
+
<span className="text-xs text-zinc-500">({dockerContainers.length})</span>
|
|
402
|
+
</div>
|
|
403
|
+
<div className="grid grid-cols-2 gap-4">
|
|
404
|
+
{dockerContainers.map((container) => (
|
|
405
|
+
<DockerCard
|
|
406
|
+
key={container.id}
|
|
407
|
+
container={container}
|
|
408
|
+
onExpose={(port) =>
|
|
409
|
+
openExposeModal(
|
|
410
|
+
container.name.replace(/[^a-z0-9-]/g, '-'),
|
|
411
|
+
port,
|
|
412
|
+
`Docker: ${container.image}`,
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
/>
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
</>
|
|
419
|
+
)}
|
|
420
|
+
</>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* Expose modal */}
|
|
424
|
+
{showExposeModal && (
|
|
425
|
+
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
426
|
+
<form
|
|
427
|
+
onSubmit={handleExpose}
|
|
428
|
+
className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 w-96"
|
|
429
|
+
>
|
|
430
|
+
<div className="flex items-center justify-between mb-4">
|
|
431
|
+
<h2 className="text-sm font-semibold text-white">Expose Service</h2>
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
onClick={() => {
|
|
435
|
+
setShowExposeModal(null);
|
|
436
|
+
setExposeError(null);
|
|
437
|
+
}}
|
|
438
|
+
className="text-zinc-500 hover:text-zinc-300"
|
|
439
|
+
>
|
|
440
|
+
<X size={16} />
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
<div className="space-y-3 mb-4">
|
|
444
|
+
<div>
|
|
445
|
+
<label className="text-xs text-zinc-500 block mb-1">Subdomain</label>
|
|
446
|
+
<input
|
|
447
|
+
type="text"
|
|
448
|
+
value={exposeData.subdomain}
|
|
449
|
+
onChange={(e) =>
|
|
450
|
+
setExposeData({ ...exposeData, subdomain: e.target.value.toLowerCase() })
|
|
451
|
+
}
|
|
452
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
|
|
453
|
+
/>
|
|
454
|
+
</div>
|
|
455
|
+
<div>
|
|
456
|
+
<label className="text-xs text-zinc-500 block mb-1">Port</label>
|
|
457
|
+
<input
|
|
458
|
+
type="number"
|
|
459
|
+
min={1024}
|
|
460
|
+
max={65535}
|
|
461
|
+
value={exposeData.port}
|
|
462
|
+
onChange={(e) => setExposeData({ ...exposeData, port: e.target.value })}
|
|
463
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
|
|
464
|
+
/>
|
|
465
|
+
</div>
|
|
466
|
+
<div>
|
|
467
|
+
<label className="text-xs text-zinc-500 block mb-1">Description</label>
|
|
468
|
+
<input
|
|
469
|
+
type="text"
|
|
470
|
+
value={exposeData.description}
|
|
471
|
+
onChange={(e) => setExposeData({ ...exposeData, description: e.target.value })}
|
|
472
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
|
|
473
|
+
/>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
{exposeError && <p className="text-red-400 text-xs mb-3">{exposeError}</p>}
|
|
477
|
+
<div className="flex gap-2">
|
|
478
|
+
<button
|
|
479
|
+
type="submit"
|
|
480
|
+
disabled={exposeMutation.isPending}
|
|
481
|
+
className="flex items-center gap-2 rounded bg-cyan-600 hover:bg-cyan-500 px-4 py-2 text-sm text-white disabled:opacity-50"
|
|
482
|
+
>
|
|
483
|
+
{exposeMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
|
484
|
+
<Globe size={14} />
|
|
485
|
+
Expose
|
|
486
|
+
</button>
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
onClick={() => {
|
|
490
|
+
setShowExposeModal(null);
|
|
491
|
+
setExposeError(null);
|
|
492
|
+
}}
|
|
493
|
+
className="rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700"
|
|
494
|
+
>
|
|
495
|
+
Cancel
|
|
496
|
+
</button>
|
|
497
|
+
</div>
|
|
498
|
+
</form>
|
|
499
|
+
</div>
|
|
500
|
+
)}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function ServiceCard({
|
|
506
|
+
service,
|
|
507
|
+
onExpose,
|
|
508
|
+
isCustom,
|
|
509
|
+
removeConfirm,
|
|
510
|
+
onRemoveClick,
|
|
511
|
+
onRemoveConfirm,
|
|
512
|
+
onRemoveCancel,
|
|
513
|
+
removeIsPending,
|
|
514
|
+
}) {
|
|
515
|
+
const isRunning = service.status === 'running';
|
|
516
|
+
const hasTunnel = !!service.tunnelFqdn;
|
|
517
|
+
const categoryColor = CATEGORY_COLORS[service.category] || CATEGORY_COLORS.custom;
|
|
518
|
+
const statusColor = STATUS_BADGE[service.status] || STATUS_BADGE.not_found;
|
|
519
|
+
const showingConfirm = removeConfirm === service.id;
|
|
520
|
+
|
|
521
|
+
return (
|
|
522
|
+
<div
|
|
523
|
+
className={`bg-zinc-900 border border-zinc-800 rounded-lg p-4 ${service.status === 'not_found' ? 'opacity-50' : ''}`}
|
|
524
|
+
>
|
|
525
|
+
<div className="flex items-start justify-between mb-2">
|
|
526
|
+
<div className="flex items-center gap-2">
|
|
527
|
+
<CategoryIcon category={service.category} size={14} />
|
|
528
|
+
<span className="text-sm font-semibold text-white">{service.name}</span>
|
|
529
|
+
</div>
|
|
530
|
+
<div className="flex items-center gap-1.5">
|
|
531
|
+
<span className={`text-xs px-2 py-0.5 rounded-full border ${categoryColor}`}>
|
|
532
|
+
{service.category}
|
|
533
|
+
</span>
|
|
534
|
+
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor}`}>
|
|
535
|
+
{service.status === 'not_found' ? 'not found' : service.status}
|
|
536
|
+
</span>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
<p className="text-xs text-zinc-500 mb-3">{service.description}</p>
|
|
540
|
+
<div className="flex items-center justify-between">
|
|
541
|
+
<span className="text-xs text-zinc-400 font-mono">
|
|
542
|
+
Port {service.defaultPort}
|
|
543
|
+
{service.detectedPort && service.detectedPort !== service.defaultPort && (
|
|
544
|
+
<span className="text-cyan-400"> → detected {service.detectedPort}</span>
|
|
545
|
+
)}
|
|
546
|
+
</span>
|
|
547
|
+
<div className="flex items-center gap-1.5">
|
|
548
|
+
{isCustom && !showingConfirm && (
|
|
549
|
+
<button
|
|
550
|
+
onClick={onRemoveClick}
|
|
551
|
+
className="text-zinc-600 hover:text-red-400 p-1"
|
|
552
|
+
title="Remove custom service"
|
|
553
|
+
>
|
|
554
|
+
<Trash2 size={12} />
|
|
555
|
+
</button>
|
|
556
|
+
)}
|
|
557
|
+
{isCustom && showingConfirm && (
|
|
558
|
+
<div className="flex items-center gap-1">
|
|
559
|
+
<button
|
|
560
|
+
onClick={onRemoveConfirm}
|
|
561
|
+
disabled={removeIsPending}
|
|
562
|
+
className="text-xs px-2 py-1 rounded bg-red-600 hover:bg-red-500 text-white disabled:opacity-50"
|
|
563
|
+
>
|
|
564
|
+
{removeIsPending ? 'Removing...' : 'Confirm'}
|
|
565
|
+
</button>
|
|
566
|
+
<button
|
|
567
|
+
onClick={onRemoveCancel}
|
|
568
|
+
className="text-xs px-2 py-1 rounded bg-zinc-800 text-zinc-400 hover:bg-zinc-700"
|
|
569
|
+
>
|
|
570
|
+
Cancel
|
|
571
|
+
</button>
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
{isRunning && !hasTunnel && (
|
|
575
|
+
<button
|
|
576
|
+
onClick={onExpose}
|
|
577
|
+
className="flex items-center gap-1 rounded bg-cyan-600 hover:bg-cyan-500 px-2.5 py-1 text-xs text-white"
|
|
578
|
+
>
|
|
579
|
+
<Globe size={12} />
|
|
580
|
+
Expose
|
|
581
|
+
</button>
|
|
582
|
+
)}
|
|
583
|
+
{hasTunnel && (
|
|
584
|
+
<span
|
|
585
|
+
className="text-xs text-cyan-400 font-mono truncate max-w-[160px]"
|
|
586
|
+
title={service.tunnelFqdn}
|
|
587
|
+
>
|
|
588
|
+
{service.tunnelFqdn}
|
|
589
|
+
</span>
|
|
590
|
+
)}
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function DockerCard({ container, onExpose }) {
|
|
598
|
+
return (
|
|
599
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
|
600
|
+
<div className="flex items-start justify-between mb-2">
|
|
601
|
+
<div className="flex items-center gap-2">
|
|
602
|
+
<Container size={14} className="text-cyan-400" />
|
|
603
|
+
<span className="text-sm font-semibold text-white">{container.name}</span>
|
|
604
|
+
</div>
|
|
605
|
+
<span className="text-xs px-2 py-0.5 rounded-full border text-cyan-400 bg-cyan-500/10 border-cyan-500/20">
|
|
606
|
+
docker
|
|
607
|
+
</span>
|
|
608
|
+
</div>
|
|
609
|
+
<p className="text-xs text-zinc-500 mb-1 font-mono truncate" title={container.image}>
|
|
610
|
+
{container.image}
|
|
611
|
+
</p>
|
|
612
|
+
<p className="text-xs text-zinc-600 mb-3">{container.status}</p>
|
|
613
|
+
{container.ports.length > 0 ? (
|
|
614
|
+
<div className="space-y-1.5">
|
|
615
|
+
{container.ports.map((port, idx) => (
|
|
616
|
+
<div key={idx} className="flex items-center justify-between">
|
|
617
|
+
<span className="text-xs text-zinc-400 font-mono">
|
|
618
|
+
:{port.hostPort} → :{port.containerPort}/{port.protocol}
|
|
619
|
+
</span>
|
|
620
|
+
{container.tunnelFqdn ? (
|
|
621
|
+
<span
|
|
622
|
+
className="text-xs text-cyan-400 font-mono truncate max-w-[160px]"
|
|
623
|
+
title={container.tunnelFqdn}
|
|
624
|
+
>
|
|
625
|
+
{container.tunnelFqdn}
|
|
626
|
+
</span>
|
|
627
|
+
) : (
|
|
628
|
+
<button
|
|
629
|
+
onClick={() => onExpose(port.hostPort)}
|
|
630
|
+
className="flex items-center gap-1 rounded bg-cyan-600 hover:bg-cyan-500 px-2.5 py-1 text-xs text-white"
|
|
631
|
+
>
|
|
632
|
+
<Globe size={12} />
|
|
633
|
+
Expose
|
|
634
|
+
</button>
|
|
635
|
+
)}
|
|
636
|
+
</div>
|
|
637
|
+
))}
|
|
638
|
+
</div>
|
|
639
|
+
) : (
|
|
640
|
+
<p className="text-xs text-zinc-600">No exposed ports</p>
|
|
641
|
+
)}
|
|
642
|
+
</div>
|
|
643
|
+
);
|
|
644
|
+
}
|