@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.
@@ -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 &quot;New Tunnel&quot; 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
+ }