@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,409 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import {
|
|
4
|
+
Settings,
|
|
5
|
+
Shield,
|
|
6
|
+
Loader2,
|
|
7
|
+
ExternalLink,
|
|
8
|
+
AlertTriangle,
|
|
9
|
+
Download,
|
|
10
|
+
RefreshCw,
|
|
11
|
+
Globe,
|
|
12
|
+
Check,
|
|
13
|
+
SkipForward,
|
|
14
|
+
X,
|
|
15
|
+
} from 'lucide-react';
|
|
16
|
+
import { useAgentClient } from '../context/AgentClientContext.jsx';
|
|
17
|
+
import { useToast } from '../components/Toast.jsx';
|
|
18
|
+
import { errorMessage } from '../lib/errorMessage.js';
|
|
19
|
+
|
|
20
|
+
export default function SettingsPage({ agentLabel, onUninstalled }) {
|
|
21
|
+
const client = useAgentClient();
|
|
22
|
+
const toast = useToast();
|
|
23
|
+
const queryClient = useQueryClient();
|
|
24
|
+
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
|
25
|
+
const [uninstallNameInput, setUninstallNameInput] = useState('');
|
|
26
|
+
const [uninstallSteps, setUninstallSteps] = useState(null);
|
|
27
|
+
|
|
28
|
+
const configQuery = useQuery({
|
|
29
|
+
queryKey: ['agent', 'config'],
|
|
30
|
+
queryFn: () => client.getConfig(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const rotateMutation = useMutation({
|
|
34
|
+
mutationFn: () => client.rotateCertificate(),
|
|
35
|
+
onSuccess: () => {
|
|
36
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'config'] });
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const downloadMutation = useMutation({
|
|
41
|
+
mutationFn: () => client.downloadCertificate(),
|
|
42
|
+
onSuccess: () => {
|
|
43
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'config'] });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const uninstallMutation = useMutation({
|
|
48
|
+
mutationFn: () => client.uninstallAgent(),
|
|
49
|
+
onSuccess: (data) => {
|
|
50
|
+
queryClient.removeQueries({ queryKey: ['agent'] });
|
|
51
|
+
setUninstallSteps(data?.steps || []);
|
|
52
|
+
},
|
|
53
|
+
onError: (err) => {
|
|
54
|
+
toast(errorMessage(err) || 'Uninstall failed', 'error');
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const panelExposeQuery = useQuery({
|
|
59
|
+
queryKey: ['agent', 'panel-expose-status'],
|
|
60
|
+
queryFn: () => client.getPanelExposeStatus(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const togglePanelMutation = useMutation({
|
|
64
|
+
mutationFn: (enabled) => client.togglePanelExpose(enabled),
|
|
65
|
+
onSuccess: () => {
|
|
66
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'panel-expose-status'] });
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const handleOpenPanel = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const url = await client.getPanelUrl();
|
|
73
|
+
await client.openExternal(url);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
toast(err?.message || 'Failed to open panel', 'error');
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const config = configQuery.data;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="p-6">
|
|
83
|
+
<h1 className="text-lg font-bold text-white mb-6">Settings</h1>
|
|
84
|
+
|
|
85
|
+
{/* Configuration */}
|
|
86
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
87
|
+
<h2 className="text-xs font-medium uppercase text-zinc-400 flex items-center gap-2">
|
|
88
|
+
<Settings size={14} className="text-cyan-400" />
|
|
89
|
+
Configuration
|
|
90
|
+
</h2>
|
|
91
|
+
|
|
92
|
+
{config ? (
|
|
93
|
+
<div className="space-y-3">
|
|
94
|
+
<div>
|
|
95
|
+
<label className="text-xs text-zinc-500 block mb-0.5">Panel URL</label>
|
|
96
|
+
<p className="text-sm text-zinc-200 font-mono">{config.panelUrl}</p>
|
|
97
|
+
</div>
|
|
98
|
+
<div>
|
|
99
|
+
<label className="text-xs text-zinc-500 block mb-0.5">Domain</label>
|
|
100
|
+
<p className="text-sm text-zinc-200 font-mono">{config.domain || 'N/A'}</p>
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<label className="text-xs text-zinc-500 block mb-0.5">Auth Method</label>
|
|
104
|
+
<p className="text-sm text-zinc-200 font-mono truncate">
|
|
105
|
+
{config.authMethod === 'keychain'
|
|
106
|
+
? 'Keychain (hardware-bound)'
|
|
107
|
+
: config.p12Path || 'P12'}
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
<div>
|
|
111
|
+
<label className="text-xs text-zinc-500 block mb-0.5">Chisel Version</label>
|
|
112
|
+
<p className="text-sm text-zinc-200 font-mono">{config.chiselVersion || 'N/A'}</p>
|
|
113
|
+
</div>
|
|
114
|
+
<div>
|
|
115
|
+
<label className="text-xs text-zinc-500 block mb-0.5">Configured At</label>
|
|
116
|
+
<p className="text-sm text-zinc-200">
|
|
117
|
+
{config.setupAt ? new Date(config.setupAt).toLocaleString() : 'N/A'}
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
{config.updatedAt && (
|
|
121
|
+
<div>
|
|
122
|
+
<label className="text-xs text-zinc-500 block mb-0.5">Last Updated</label>
|
|
123
|
+
<p className="text-sm text-zinc-200">
|
|
124
|
+
{new Date(config.updatedAt).toLocaleString()}
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
) : configQuery.isError ? (
|
|
130
|
+
<p className="text-red-400 text-sm">Failed to load configuration</p>
|
|
131
|
+
) : (
|
|
132
|
+
<p className="text-zinc-500 text-sm">Loading...</p>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Panel Link */}
|
|
137
|
+
<div className="mt-4 bg-zinc-900 border border-zinc-800 rounded-lg p-5">
|
|
138
|
+
<h2 className="text-xs font-medium uppercase text-zinc-400 mb-3 flex items-center gap-2">
|
|
139
|
+
<ExternalLink size={14} className="text-cyan-400" />
|
|
140
|
+
Panel
|
|
141
|
+
</h2>
|
|
142
|
+
<button
|
|
143
|
+
onClick={handleOpenPanel}
|
|
144
|
+
className="flex items-center gap-2 rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700"
|
|
145
|
+
>
|
|
146
|
+
<ExternalLink size={14} />
|
|
147
|
+
Open Panel in Browser
|
|
148
|
+
</button>
|
|
149
|
+
<p className="text-xs text-zinc-500 mt-2">
|
|
150
|
+
Opens the Lamaste web panel in your default browser (requires imported certificate).
|
|
151
|
+
</p>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Web Panel */}
|
|
155
|
+
<div className="mt-4 bg-zinc-900 border border-zinc-800 rounded-lg p-5">
|
|
156
|
+
<h2 className="text-xs font-medium uppercase text-zinc-400 mb-3 flex items-center gap-2">
|
|
157
|
+
<Globe size={14} className="text-cyan-400" />
|
|
158
|
+
Web Panel
|
|
159
|
+
</h2>
|
|
160
|
+
{panelExposeQuery.isError ? (
|
|
161
|
+
<p className="text-zinc-500 text-sm">
|
|
162
|
+
Not available (agent may lack{' '}
|
|
163
|
+
<span className="font-mono text-zinc-400">panel:expose</span> capability)
|
|
164
|
+
</p>
|
|
165
|
+
) : (
|
|
166
|
+
<>
|
|
167
|
+
<div className="flex items-center justify-between">
|
|
168
|
+
<div>
|
|
169
|
+
<p className="text-sm text-zinc-300">Expose management panel</p>
|
|
170
|
+
<p className="text-xs text-zinc-500 mt-0.5">
|
|
171
|
+
{panelExposeQuery.data?.enabled ? (
|
|
172
|
+
<>
|
|
173
|
+
Accessible at{' '}
|
|
174
|
+
<span className="font-mono text-cyan-400">{panelExposeQuery.data.fqdn}</span>
|
|
175
|
+
</>
|
|
176
|
+
) : (
|
|
177
|
+
'Make this agent panel accessible via a web subdomain'
|
|
178
|
+
)}
|
|
179
|
+
</p>
|
|
180
|
+
</div>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={() => togglePanelMutation.mutate(!panelExposeQuery.data?.enabled)}
|
|
184
|
+
disabled={togglePanelMutation.isPending || panelExposeQuery.isPending}
|
|
185
|
+
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
186
|
+
panelExposeQuery.data?.enabled ? 'bg-cyan-500' : 'bg-zinc-700'
|
|
187
|
+
}`}
|
|
188
|
+
>
|
|
189
|
+
<span
|
|
190
|
+
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
191
|
+
panelExposeQuery.data?.enabled ? 'translate-x-5' : 'translate-x-0'
|
|
192
|
+
}`}
|
|
193
|
+
/>
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
{togglePanelMutation.isPending && (
|
|
197
|
+
<div className="flex items-center gap-2 text-xs text-zinc-400 mt-2">
|
|
198
|
+
<Loader2 size={12} className="animate-spin" />
|
|
199
|
+
{panelExposeQuery.data?.enabled ? 'Retracting...' : 'Exposing...'}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
{togglePanelMutation.isError && (
|
|
203
|
+
<p className="text-red-400 text-xs mt-2">
|
|
204
|
+
{togglePanelMutation.error?.message || 'Failed to toggle web panel'}
|
|
205
|
+
</p>
|
|
206
|
+
)}
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Certificate Management */}
|
|
212
|
+
<div className="mt-4 bg-zinc-900 border border-zinc-800 rounded-lg p-5">
|
|
213
|
+
<h2 className="text-xs font-medium uppercase text-zinc-400 mb-3 flex items-center gap-2">
|
|
214
|
+
<Shield size={14} className="text-cyan-400" />
|
|
215
|
+
Certificate
|
|
216
|
+
</h2>
|
|
217
|
+
<div className="flex gap-3">
|
|
218
|
+
<button
|
|
219
|
+
onClick={() => rotateMutation.mutate()}
|
|
220
|
+
disabled={rotateMutation.isPending || downloadMutation.isPending}
|
|
221
|
+
className="flex items-center gap-2 rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
222
|
+
>
|
|
223
|
+
{rotateMutation.isPending ? (
|
|
224
|
+
<Loader2 size={14} className="animate-spin" />
|
|
225
|
+
) : (
|
|
226
|
+
<RefreshCw size={14} />
|
|
227
|
+
)}
|
|
228
|
+
Rotate Certificate
|
|
229
|
+
</button>
|
|
230
|
+
<button
|
|
231
|
+
onClick={() => downloadMutation.mutate()}
|
|
232
|
+
disabled={rotateMutation.isPending || downloadMutation.isPending}
|
|
233
|
+
className="flex items-center gap-2 rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
234
|
+
>
|
|
235
|
+
{downloadMutation.isPending ? (
|
|
236
|
+
<Loader2 size={14} className="animate-spin" />
|
|
237
|
+
) : (
|
|
238
|
+
<Download size={14} />
|
|
239
|
+
)}
|
|
240
|
+
Download Certificate
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
<p className="text-xs text-zinc-500 mt-2">
|
|
244
|
+
Rotate generates a new certificate on the server and downloads it. Download re-fetches the
|
|
245
|
+
current certificate.
|
|
246
|
+
</p>
|
|
247
|
+
{rotateMutation.isSuccess && (
|
|
248
|
+
<p className="text-green-400 text-xs mt-2">
|
|
249
|
+
Certificate rotated
|
|
250
|
+
{rotateMutation.data?.expiresAt
|
|
251
|
+
? ` — expires ${new Date(rotateMutation.data.expiresAt).toLocaleDateString()}`
|
|
252
|
+
: ''}
|
|
253
|
+
.
|
|
254
|
+
{rotateMutation.data?.warning && (
|
|
255
|
+
<span className="text-amber-400"> Warning: {rotateMutation.data.warning}</span>
|
|
256
|
+
)}
|
|
257
|
+
</p>
|
|
258
|
+
)}
|
|
259
|
+
{rotateMutation.isError && (
|
|
260
|
+
<p className="text-red-400 text-xs mt-2">
|
|
261
|
+
{rotateMutation.error?.message || 'Rotation failed'}
|
|
262
|
+
</p>
|
|
263
|
+
)}
|
|
264
|
+
{downloadMutation.isSuccess && (
|
|
265
|
+
<p className="text-green-400 text-xs mt-2">
|
|
266
|
+
Certificate saved to {downloadMutation.data}
|
|
267
|
+
</p>
|
|
268
|
+
)}
|
|
269
|
+
{downloadMutation.isError && (
|
|
270
|
+
<p className="text-red-400 text-xs mt-2">
|
|
271
|
+
{downloadMutation.error?.message || 'Download failed'}
|
|
272
|
+
</p>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{/* About */}
|
|
277
|
+
<div className="mt-4 bg-zinc-900 border border-zinc-800 rounded-lg p-5">
|
|
278
|
+
<h2 className="text-xs font-medium uppercase text-zinc-400 mb-3">About</h2>
|
|
279
|
+
<p className="text-sm text-zinc-300">Lamaste Desktop v0.1.0</p>
|
|
280
|
+
<p className="text-xs text-zinc-500 mt-1">Cross-platform tunnel agent with system tray</p>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* Danger Zone */}
|
|
284
|
+
<div className="mt-4 bg-zinc-900 border border-red-500/30 rounded-lg p-5">
|
|
285
|
+
<h2 className="text-xs font-medium uppercase text-red-400 mb-3 flex items-center gap-2">
|
|
286
|
+
<AlertTriangle size={14} />
|
|
287
|
+
Danger Zone
|
|
288
|
+
</h2>
|
|
289
|
+
{uninstallSteps ? (
|
|
290
|
+
/* Post-uninstall: show step results */
|
|
291
|
+
<div className="space-y-3">
|
|
292
|
+
<p className="text-sm text-zinc-300 font-medium">
|
|
293
|
+
Agent{' '}
|
|
294
|
+
{agentLabel ? <span className="font-mono text-cyan-400">{agentLabel}</span> : ''}{' '}
|
|
295
|
+
uninstalled
|
|
296
|
+
</p>
|
|
297
|
+
<div className="space-y-1.5">
|
|
298
|
+
{uninstallSteps.map((s, i) => (
|
|
299
|
+
<div key={i} className="flex items-start gap-2 text-xs">
|
|
300
|
+
{s.status === 'complete' && (
|
|
301
|
+
<Check size={13} className="text-green-400 mt-0.5 flex-shrink-0" />
|
|
302
|
+
)}
|
|
303
|
+
{s.status === 'skipped' && (
|
|
304
|
+
<SkipForward size={13} className="text-zinc-500 mt-0.5 flex-shrink-0" />
|
|
305
|
+
)}
|
|
306
|
+
{s.status === 'warning' && (
|
|
307
|
+
<AlertTriangle size={13} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
|
308
|
+
)}
|
|
309
|
+
{s.status === 'failed' && (
|
|
310
|
+
<X size={13} className="text-red-400 mt-0.5 flex-shrink-0" />
|
|
311
|
+
)}
|
|
312
|
+
<div>
|
|
313
|
+
<span
|
|
314
|
+
className={
|
|
315
|
+
s.status === 'complete'
|
|
316
|
+
? 'text-zinc-300'
|
|
317
|
+
: s.status === 'skipped'
|
|
318
|
+
? 'text-zinc-500'
|
|
319
|
+
: s.status === 'warning'
|
|
320
|
+
? 'text-amber-400'
|
|
321
|
+
: 'text-red-400'
|
|
322
|
+
}
|
|
323
|
+
>
|
|
324
|
+
{s.step}
|
|
325
|
+
</span>
|
|
326
|
+
<span className="text-zinc-600 ml-1.5">{s.detail}</span>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
))}
|
|
330
|
+
</div>
|
|
331
|
+
{onUninstalled ? (
|
|
332
|
+
<button
|
|
333
|
+
onClick={onUninstalled}
|
|
334
|
+
className="mt-2 rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700"
|
|
335
|
+
>
|
|
336
|
+
Done
|
|
337
|
+
</button>
|
|
338
|
+
) : (
|
|
339
|
+
<p className="mt-2 text-xs text-zinc-500">
|
|
340
|
+
Agent has been uninstalled. You can close this page.
|
|
341
|
+
</p>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
) : !uninstallConfirm ? (
|
|
345
|
+
<>
|
|
346
|
+
<button
|
|
347
|
+
onClick={() => {
|
|
348
|
+
setUninstallConfirm(true);
|
|
349
|
+
setUninstallNameInput('');
|
|
350
|
+
}}
|
|
351
|
+
className="flex items-center gap-2 rounded bg-red-600/20 border border-red-500/30 px-4 py-2 text-sm text-red-400 hover:bg-red-600/30"
|
|
352
|
+
>
|
|
353
|
+
Uninstall Agent
|
|
354
|
+
</button>
|
|
355
|
+
<p className="text-xs text-zinc-500 mt-2">
|
|
356
|
+
Remove all Lamaste agent files including the chisel binary, configuration, and logs.
|
|
357
|
+
</p>
|
|
358
|
+
</>
|
|
359
|
+
) : (
|
|
360
|
+
<div className="rounded bg-red-600/10 border border-red-500/30 p-4">
|
|
361
|
+
<p className="text-sm text-red-300 mb-3">
|
|
362
|
+
This will remove all Lamaste agent files including the chisel binary, configuration,
|
|
363
|
+
and logs. This cannot be undone.
|
|
364
|
+
</p>
|
|
365
|
+
{agentLabel && (
|
|
366
|
+
<div className="mb-3">
|
|
367
|
+
<label className="text-xs text-zinc-400 block mb-1">
|
|
368
|
+
Type <span className="font-mono text-red-400">{agentLabel}</span> to confirm
|
|
369
|
+
</label>
|
|
370
|
+
<input
|
|
371
|
+
type="text"
|
|
372
|
+
value={uninstallNameInput}
|
|
373
|
+
onChange={(e) => setUninstallNameInput(e.target.value)}
|
|
374
|
+
placeholder={agentLabel}
|
|
375
|
+
className="w-full rounded bg-zinc-950 border border-zinc-700 px-3 py-1.5 text-sm text-zinc-200 font-mono placeholder:text-zinc-600 focus:outline-none focus:border-red-500/50"
|
|
376
|
+
autoFocus
|
|
377
|
+
/>
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
<div className="flex gap-2">
|
|
381
|
+
<button
|
|
382
|
+
onClick={() => uninstallMutation.mutate()}
|
|
383
|
+
disabled={
|
|
384
|
+
uninstallMutation.isPending || (agentLabel && uninstallNameInput !== agentLabel)
|
|
385
|
+
}
|
|
386
|
+
className="flex items-center gap-2 rounded bg-red-600 hover:bg-red-500 px-4 py-2 text-sm text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
387
|
+
>
|
|
388
|
+
{uninstallMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
|
389
|
+
Uninstall
|
|
390
|
+
</button>
|
|
391
|
+
<button
|
|
392
|
+
onClick={() => setUninstallConfirm(false)}
|
|
393
|
+
disabled={uninstallMutation.isPending}
|
|
394
|
+
className="rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700 disabled:opacity-50"
|
|
395
|
+
>
|
|
396
|
+
Cancel
|
|
397
|
+
</button>
|
|
398
|
+
</div>
|
|
399
|
+
{uninstallMutation.isError && (
|
|
400
|
+
<p className="text-red-400 text-xs mt-2">
|
|
401
|
+
{uninstallMutation.error?.message || 'Uninstall failed'}
|
|
402
|
+
</p>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Network, Loader2, Plus, Trash2, X, Power } from 'lucide-react';
|
|
4
|
+
import { useAgentClient } from '../context/AgentClientContext.jsx';
|
|
5
|
+
|
|
6
|
+
export default function Tunnels() {
|
|
7
|
+
const client = useAgentClient();
|
|
8
|
+
const queryClient = useQueryClient();
|
|
9
|
+
const [showForm, setShowForm] = useState(false);
|
|
10
|
+
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
|
11
|
+
const [formData, setFormData] = useState({ subdomain: '', port: '', description: '' });
|
|
12
|
+
const [formError, setFormError] = useState(null);
|
|
13
|
+
|
|
14
|
+
const tunnelsQuery = useQuery({
|
|
15
|
+
queryKey: ['agent', 'tunnels'],
|
|
16
|
+
queryFn: () => client.getTunnels(),
|
|
17
|
+
refetchInterval: 10000,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const invalidateAll = () => {
|
|
21
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'tunnels'] });
|
|
22
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'status'] });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const createMutation = useMutation({
|
|
26
|
+
mutationFn: async (data) => {
|
|
27
|
+
await client.createTunnel({
|
|
28
|
+
subdomain: data.subdomain,
|
|
29
|
+
port: parseInt(data.port, 10),
|
|
30
|
+
description: data.description || '',
|
|
31
|
+
});
|
|
32
|
+
await client.updateAgent();
|
|
33
|
+
},
|
|
34
|
+
onSuccess: () => {
|
|
35
|
+
setShowForm(false);
|
|
36
|
+
setFormData({ subdomain: '', port: '', description: '' });
|
|
37
|
+
setFormError(null);
|
|
38
|
+
invalidateAll();
|
|
39
|
+
},
|
|
40
|
+
onError: (err) => {
|
|
41
|
+
setFormError(err?.message || String(err));
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const toggleMutation = useMutation({
|
|
46
|
+
mutationFn: async ({ id, enabled }) => {
|
|
47
|
+
await client.toggleTunnel(id, { enabled });
|
|
48
|
+
await client.updateAgent();
|
|
49
|
+
},
|
|
50
|
+
onSuccess: () => {
|
|
51
|
+
invalidateAll();
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const deleteMutation = useMutation({
|
|
56
|
+
mutationFn: async (id) => {
|
|
57
|
+
await client.deleteTunnel(id);
|
|
58
|
+
await client.updateAgent();
|
|
59
|
+
},
|
|
60
|
+
onSuccess: () => {
|
|
61
|
+
setDeleteConfirm(null);
|
|
62
|
+
invalidateAll();
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const tunnels = tunnelsQuery.data || [];
|
|
67
|
+
|
|
68
|
+
const handleSubmit = (e) => {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
setFormError(null);
|
|
71
|
+
|
|
72
|
+
const port = parseInt(formData.port, 10);
|
|
73
|
+
if (!formData.subdomain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(formData.subdomain)) {
|
|
74
|
+
setFormError('Subdomain must be lowercase alphanumeric with optional hyphens');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (formData.subdomain.length > 63) {
|
|
78
|
+
setFormError('Subdomain must be at most 63 characters');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
82
|
+
setFormError('Port must be between 1024 and 65535');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
createMutation.mutate(formData);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="p-6">
|
|
91
|
+
<div className="flex items-center justify-between mb-6">
|
|
92
|
+
<h1 className="text-lg font-bold text-white">Tunnels</h1>
|
|
93
|
+
{!showForm && (
|
|
94
|
+
<button
|
|
95
|
+
onClick={() => setShowForm(true)}
|
|
96
|
+
className="flex items-center gap-1.5 rounded bg-cyan-600 hover:bg-cyan-500 px-3 py-1.5 text-sm text-white"
|
|
97
|
+
>
|
|
98
|
+
<Plus size={14} />
|
|
99
|
+
New Tunnel
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Create form */}
|
|
105
|
+
{showForm && (
|
|
106
|
+
<form
|
|
107
|
+
onSubmit={handleSubmit}
|
|
108
|
+
className="bg-zinc-900 border border-zinc-800 rounded-lg p-5 mb-4"
|
|
109
|
+
>
|
|
110
|
+
<div className="flex items-center justify-between mb-4">
|
|
111
|
+
<h2 className="text-sm font-semibold text-white">Create Tunnel</h2>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => {
|
|
115
|
+
setShowForm(false);
|
|
116
|
+
setFormError(null);
|
|
117
|
+
}}
|
|
118
|
+
className="text-zinc-500 hover:text-zinc-300"
|
|
119
|
+
>
|
|
120
|
+
<X size={16} />
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="grid grid-cols-2 gap-3 mb-3">
|
|
124
|
+
<div>
|
|
125
|
+
<label className="text-xs text-zinc-500 block mb-1">Subdomain</label>
|
|
126
|
+
<input
|
|
127
|
+
type="text"
|
|
128
|
+
placeholder="myapp"
|
|
129
|
+
value={formData.subdomain}
|
|
130
|
+
onChange={(e) =>
|
|
131
|
+
setFormData({ ...formData, subdomain: e.target.value.toLowerCase() })
|
|
132
|
+
}
|
|
133
|
+
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"
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
<div>
|
|
137
|
+
<label className="text-xs text-zinc-500 block mb-1">Port</label>
|
|
138
|
+
<input
|
|
139
|
+
type="number"
|
|
140
|
+
placeholder="3000"
|
|
141
|
+
min={1024}
|
|
142
|
+
max={65535}
|
|
143
|
+
value={formData.port}
|
|
144
|
+
onChange={(e) => setFormData({ ...formData, port: e.target.value })}
|
|
145
|
+
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"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="mb-4">
|
|
150
|
+
<label className="text-xs text-zinc-500 block mb-1">Description (optional)</label>
|
|
151
|
+
<input
|
|
152
|
+
type="text"
|
|
153
|
+
placeholder="My web app"
|
|
154
|
+
value={formData.description}
|
|
155
|
+
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
156
|
+
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"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
{formError && <p className="text-red-400 text-xs mb-3">{formError}</p>}
|
|
160
|
+
<div className="flex gap-2">
|
|
161
|
+
<button
|
|
162
|
+
type="submit"
|
|
163
|
+
disabled={createMutation.isPending}
|
|
164
|
+
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"
|
|
165
|
+
>
|
|
166
|
+
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
|
167
|
+
Create Tunnel
|
|
168
|
+
</button>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={() => {
|
|
172
|
+
setShowForm(false);
|
|
173
|
+
setFormError(null);
|
|
174
|
+
}}
|
|
175
|
+
className="rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700"
|
|
176
|
+
>
|
|
177
|
+
Cancel
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</form>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{tunnelsQuery.isLoading ? (
|
|
184
|
+
<div className="flex items-center gap-2 text-zinc-500">
|
|
185
|
+
<Loader2 size={14} className="animate-spin" />
|
|
186
|
+
Loading tunnels...
|
|
187
|
+
</div>
|
|
188
|
+
) : tunnels.length === 0 && !showForm ? (
|
|
189
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-8 text-center">
|
|
190
|
+
<Network size={32} className="mx-auto text-zinc-600 mb-3" />
|
|
191
|
+
<p className="text-zinc-400 text-sm">No tunnels configured.</p>
|
|
192
|
+
<p className="text-zinc-500 text-xs mt-1">
|
|
193
|
+
Click "New Tunnel" to expose a local service.
|
|
194
|
+
</p>
|
|
195
|
+
</div>
|
|
196
|
+
) : (
|
|
197
|
+
<div className="space-y-3">
|
|
198
|
+
{tunnels.map((tunnel) => {
|
|
199
|
+
const enabled = tunnel.enabled !== false;
|
|
200
|
+
return (
|
|
201
|
+
<div
|
|
202
|
+
key={tunnel.id || tunnel.fqdn}
|
|
203
|
+
className={`bg-zinc-900 border border-zinc-800 rounded-lg p-4 ${!enabled ? 'opacity-60' : ''}`}
|
|
204
|
+
>
|
|
205
|
+
<div className="flex items-center justify-between">
|
|
206
|
+
<div>
|
|
207
|
+
<div className="flex items-center gap-2">
|
|
208
|
+
<p className="text-sm text-white font-semibold">{tunnel.fqdn}</p>
|
|
209
|
+
<span
|
|
210
|
+
className={`text-xs px-2 py-0.5 rounded-full border ${
|
|
211
|
+
enabled
|
|
212
|
+
? 'text-green-400 bg-green-500/10 border-green-500/20'
|
|
213
|
+
: 'text-zinc-500 bg-zinc-800 border-zinc-700'
|
|
214
|
+
}`}
|
|
215
|
+
>
|
|
216
|
+
{enabled ? 'active' : 'disabled'}
|
|
217
|
+
</span>
|
|
218
|
+
</div>
|
|
219
|
+
<p className="text-xs text-zinc-500 mt-0.5">
|
|
220
|
+
localhost:{tunnel.port}
|
|
221
|
+
{tunnel.description && ` — ${tunnel.description}`}
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
<div className="flex items-center gap-2">
|
|
225
|
+
<button
|
|
226
|
+
onClick={() => toggleMutation.mutate({ id: tunnel.id, enabled: !enabled })}
|
|
227
|
+
disabled={toggleMutation.isPending}
|
|
228
|
+
className={`p-1.5 rounded hover:bg-zinc-800 ${enabled ? 'text-yellow-400' : 'text-green-400'}`}
|
|
229
|
+
title={enabled ? 'Disable tunnel' : 'Enable tunnel'}
|
|
230
|
+
>
|
|
231
|
+
<Power size={14} />
|
|
232
|
+
</button>
|
|
233
|
+
{deleteConfirm === tunnel.id ? (
|
|
234
|
+
<div className="flex items-center gap-1">
|
|
235
|
+
<button
|
|
236
|
+
onClick={() => deleteMutation.mutate(tunnel.id)}
|
|
237
|
+
disabled={deleteMutation.isPending}
|
|
238
|
+
className="text-xs px-2 py-1 rounded bg-red-600 hover:bg-red-500 text-white disabled:opacity-50"
|
|
239
|
+
>
|
|
240
|
+
{deleteMutation.isPending ? 'Deleting...' : 'Confirm'}
|
|
241
|
+
</button>
|
|
242
|
+
<button
|
|
243
|
+
onClick={() => setDeleteConfirm(null)}
|
|
244
|
+
className="text-xs px-2 py-1 rounded bg-zinc-800 text-zinc-400 hover:bg-zinc-700"
|
|
245
|
+
>
|
|
246
|
+
Cancel
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
) : (
|
|
250
|
+
<button
|
|
251
|
+
onClick={() => setDeleteConfirm(tunnel.id)}
|
|
252
|
+
className="text-zinc-600 hover:text-red-400 p-1"
|
|
253
|
+
title="Delete tunnel"
|
|
254
|
+
>
|
|
255
|
+
<Trash2 size={14} />
|
|
256
|
+
</button>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
{deleteMutation.isError && (
|
|
267
|
+
<p className="text-red-400 text-xs mt-3">
|
|
268
|
+
{deleteMutation.error?.message || 'Delete failed'}
|
|
269
|
+
</p>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|