lobstakit-cloud 1.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,1274 @@
1
+ /**
2
+ * LobstaKit Cloud — Management Dashboard
3
+ */
4
+
5
+ // ─── Auth Helpers ────────────────────────────────────────────
6
+
7
+ function authHeaders(extra) {
8
+ const token = localStorage.getItem('lobstakit_token');
9
+ const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
10
+ return extra ? Object.assign(headers, extra) : headers;
11
+ }
12
+
13
+ function authFetchOpts(opts) {
14
+ opts = opts || {};
15
+ opts.headers = authHeaders(opts.headers);
16
+ return opts;
17
+ }
18
+
19
+ async function logout() {
20
+ const token = localStorage.getItem('lobstakit_token');
21
+ if (token) {
22
+ try {
23
+ await fetch('/api/auth/logout', {
24
+ method: 'POST',
25
+ headers: { 'Authorization': `Bearer ${token}` }
26
+ });
27
+ } catch (e) { /* ignore */ }
28
+ }
29
+ localStorage.removeItem('lobstakit_token');
30
+ window.location.href = '/login.html';
31
+ }
32
+
33
+ async function loadAccountEmail() {
34
+ try {
35
+ const res = await fetch('/api/auth/status', { headers: authHeaders() });
36
+ const data = await res.json();
37
+ const emailEl = document.getElementById('account-email');
38
+ if (emailEl && data.email) {
39
+ emailEl.value = data.email;
40
+ }
41
+ } catch (e) { /* ignore */ }
42
+ }
43
+
44
+ async function changePassword() {
45
+ const currentPw = document.getElementById('current-password').value;
46
+ const newEmail = document.getElementById('new-email').value.trim();
47
+ const newPw = document.getElementById('new-password').value;
48
+ const confirmPw = document.getElementById('confirm-new-password').value;
49
+ const errorEl = document.getElementById('password-change-error');
50
+ const successEl = document.getElementById('password-change-success');
51
+ const btn = document.getElementById('btn-change-password');
52
+
53
+ errorEl.classList.add('hidden');
54
+ successEl.classList.add('hidden');
55
+
56
+ if (!currentPw) { errorEl.textContent = 'Current password is required'; errorEl.classList.remove('hidden'); return; }
57
+
58
+ // Must change at least something
59
+ if (!newPw && !newEmail) { errorEl.textContent = 'Enter a new email or new password to update'; errorEl.classList.remove('hidden'); return; }
60
+
61
+ // Validate new email if provided
62
+ if (newEmail && !newEmail.includes('@')) { errorEl.textContent = 'Please enter a valid email address'; errorEl.classList.remove('hidden'); return; }
63
+
64
+ // Validate new password if provided
65
+ if (newPw && newPw.length < 6) { errorEl.textContent = 'New password must be at least 6 characters'; errorEl.classList.remove('hidden'); return; }
66
+ if (newPw && newPw !== confirmPw) { errorEl.textContent = 'Passwords do not match'; errorEl.classList.remove('hidden'); return; }
67
+
68
+ btn.disabled = true;
69
+ btn.textContent = 'Updating...';
70
+
71
+ const body = { currentPassword: currentPw };
72
+ if (newPw) body.newPassword = newPw;
73
+ if (newEmail) body.newEmail = newEmail;
74
+
75
+ try {
76
+ const res = await fetch('/api/auth/change', {
77
+ method: 'POST',
78
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
79
+ body: JSON.stringify(body)
80
+ });
81
+ const data = await res.json();
82
+
83
+ if (res.ok && data.status === 'ok') {
84
+ if (newPw) {
85
+ successEl.textContent = 'Account updated! Please log in again.';
86
+ successEl.classList.remove('hidden');
87
+ // Sessions invalidated — redirect to login after a moment
88
+ setTimeout(() => {
89
+ localStorage.removeItem('lobstakit_token');
90
+ window.location.href = '/login.html';
91
+ }, 2000);
92
+ } else {
93
+ successEl.textContent = 'Email updated successfully!';
94
+ successEl.classList.remove('hidden');
95
+ // Reload account email display
96
+ loadAccountEmail();
97
+ }
98
+ document.getElementById('current-password').value = '';
99
+ document.getElementById('new-email').value = '';
100
+ document.getElementById('new-password').value = '';
101
+ document.getElementById('confirm-new-password').value = '';
102
+ } else {
103
+ errorEl.textContent = data.error || 'Failed to update account';
104
+ errorEl.classList.remove('hidden');
105
+ }
106
+ } catch (e) {
107
+ errorEl.textContent = 'Connection error';
108
+ errorEl.classList.remove('hidden');
109
+ } finally {
110
+ btn.disabled = false;
111
+ btn.textContent = 'Update Account';
112
+ }
113
+ }
114
+
115
+ // ─── Init ────────────────────────────────────────────────────
116
+
117
+ document.addEventListener('DOMContentLoaded', async () => {
118
+ // Auth check
119
+ const token = localStorage.getItem('lobstakit_token');
120
+ if (!token) {
121
+ window.location.href = '/login.html';
122
+ return;
123
+ }
124
+ try {
125
+ const authRes = await fetch('/api/auth/status', {
126
+ headers: { 'Authorization': `Bearer ${token}` }
127
+ });
128
+ const authData = await authRes.json();
129
+ if (!authData.authenticated) {
130
+ localStorage.removeItem('lobstakit_token');
131
+ window.location.href = '/login.html';
132
+ return;
133
+ }
134
+ } catch (e) {
135
+ // If we can't reach the server, still try to load
136
+ }
137
+
138
+ fetchStatus();
139
+ refreshLogs();
140
+ refreshSecurityStatus();
141
+ refreshTailscaleStatus();
142
+ refreshChannels();
143
+ refreshMemoryStatus();
144
+ refreshGatewayInfo();
145
+ loadAccountEmail();
146
+
147
+ // Auto-refresh
148
+ setInterval(fetchStatus, 10000);
149
+ setInterval(refreshLogs, 15000);
150
+ setInterval(refreshSecurityStatus, 30000);
151
+ setInterval(refreshTailscaleStatus, 30000);
152
+ setInterval(refreshChannels, 30000);
153
+ setInterval(refreshMemoryStatus, 30000);
154
+ setInterval(refreshGatewayInfo, 30000);
155
+ });
156
+
157
+ // ─── Gateway Status ──────────────────────────────────────────
158
+
159
+ async function fetchStatus() {
160
+ try {
161
+ const statusRes = await fetch('/api/status', { headers: authHeaders() });
162
+ const statusData = await statusRes.json();
163
+
164
+ let gatewayRunning = statusData.gatewayRunning;
165
+ try {
166
+ const gwRes = await fetch('/api/gateway-status', { headers: authHeaders() });
167
+ const gwData = await gwRes.json();
168
+ gatewayRunning = gwData.running;
169
+ } catch (e) {
170
+ // Use status data fallback
171
+ }
172
+
173
+ updateDashboard(statusData, gatewayRunning);
174
+ } catch (err) {
175
+ console.error('Failed to fetch status:', err);
176
+ }
177
+ }
178
+
179
+ function updateDashboard(data, gatewayRunning) {
180
+ // Gateway status
181
+ const gatewayEl = document.getElementById('gateway-status');
182
+ if (gatewayRunning) {
183
+ gatewayEl.textContent = 'Running';
184
+ gatewayEl.className = 'font-semibold text-lg text-green-400';
185
+ } else {
186
+ gatewayEl.textContent = 'Stopped';
187
+ gatewayEl.className = 'font-semibold text-lg text-red-400';
188
+ }
189
+
190
+ // Config status
191
+ const configEl = document.getElementById('config-status');
192
+ if (data.configured) {
193
+ configEl.textContent = 'Ready';
194
+ configEl.className = 'font-semibold text-lg text-green-400';
195
+ } else {
196
+ configEl.textContent = 'Not Set';
197
+ configEl.className = 'font-semibold text-lg text-yellow-400';
198
+ }
199
+
200
+ // Domain
201
+ const domainEl = document.getElementById('domain-status');
202
+ if (data.subdomain) {
203
+ domainEl.textContent = data.subdomain;
204
+ domainEl.className = 'font-semibold text-lg text-lobsta-light truncate max-w-[160px]';
205
+ } else {
206
+ domainEl.textContent = 'Not set';
207
+ domainEl.className = 'font-semibold text-lg text-gray-400';
208
+ }
209
+
210
+ // Health status
211
+ const healthEl = document.getElementById('health-status');
212
+ if (gatewayRunning && data.configured) {
213
+ healthEl.textContent = 'Healthy';
214
+ healthEl.className = 'font-semibold text-lg text-green-400';
215
+ } else if (data.configured && !gatewayRunning) {
216
+ healthEl.textContent = 'Degraded';
217
+ healthEl.className = 'font-semibold text-lg text-yellow-400';
218
+ } else {
219
+ healthEl.textContent = 'Offline';
220
+ healthEl.className = 'font-semibold text-lg text-gray-400';
221
+ }
222
+
223
+ // Config sidebar info
224
+ document.getElementById('info-model').textContent = formatModel(data.model) || '—';
225
+ document.getElementById('info-channel').textContent = data.channel ? capitalize(data.channel) : '—';
226
+ document.getElementById('info-domain').textContent = data.subdomain || '—';
227
+ }
228
+
229
+ // ─── Logs ────────────────────────────────────────────────────
230
+
231
+ async function refreshLogs() {
232
+ try {
233
+ const res = await fetch('/api/logs?lines=50', { headers: authHeaders() });
234
+ const data = await res.json();
235
+ const logViewer = document.getElementById('log-viewer');
236
+ logViewer.textContent = data.logs || 'No logs available';
237
+ logViewer.scrollTop = logViewer.scrollHeight;
238
+ } catch (err) {
239
+ console.error('Failed to fetch logs:', err);
240
+ document.getElementById('log-viewer').textContent = 'Failed to load logs';
241
+ }
242
+ }
243
+
244
+ // ─── Actions ─────────────────────────────────────────────────
245
+
246
+ async function restartGateway() {
247
+ const btn = document.getElementById('btn-restart');
248
+ btn.disabled = true;
249
+ btn.textContent = '🔄 Restarting...';
250
+ showToast('Restarting gateway...', 'info');
251
+
252
+ try {
253
+ const res = await fetch('/api/restart', authFetchOpts({ method: 'POST' }));
254
+ const data = await res.json();
255
+
256
+ if (data.success) {
257
+ showToast('Gateway restarted successfully', 'success');
258
+ btn.textContent = '✓ Restarted';
259
+ setTimeout(() => {
260
+ btn.textContent = '🔄 Restart Gateway';
261
+ btn.disabled = false;
262
+ fetchStatus();
263
+ refreshLogs();
264
+ }, 2000);
265
+ } else {
266
+ showToast(data.error || 'Restart failed', 'error');
267
+ btn.textContent = '✗ Failed';
268
+ setTimeout(() => {
269
+ btn.textContent = '🔄 Restart Gateway';
270
+ btn.disabled = false;
271
+ }, 2000);
272
+ }
273
+ } catch (err) {
274
+ showToast('Network error', 'error');
275
+ btn.textContent = '✗ Error';
276
+ setTimeout(() => {
277
+ btn.textContent = '🔄 Restart Gateway';
278
+ btn.disabled = false;
279
+ }, 2000);
280
+ }
281
+ }
282
+
283
+ function refreshAll() {
284
+ showToast('Refreshing...', 'info');
285
+ fetchStatus();
286
+ refreshLogs();
287
+ refreshSecurityStatus();
288
+ refreshTailscaleStatus();
289
+ refreshChannels();
290
+ refreshMemoryStatus();
291
+ refreshGatewayInfo();
292
+ setTimeout(() => showToast('Dashboard updated', 'success'), 500);
293
+ }
294
+
295
+ // ─── Security Status ─────────────────────────────────────────
296
+
297
+ async function refreshSecurityStatus() {
298
+ try {
299
+ const res = await fetch('/api/security/status', { headers: authHeaders() });
300
+ const data = await res.json();
301
+ updateSecurityUI(data);
302
+ } catch (err) {
303
+ console.error('Failed to fetch security status:', err);
304
+ }
305
+ }
306
+
307
+ function updateSecurityUI(data) {
308
+ // Update each checklist item
309
+ setSecurityItem('firewall', data.firewall?.active, data.firewall?.installed);
310
+ setSecurityItem('fail2ban', data.fail2ban?.active, data.fail2ban?.installed);
311
+ setSecurityItem('ssh', data.ssh?.hardened, data.ssh?.hardened);
312
+ setSecurityItem('kernel', data.kernel?.hardened, data.kernel?.hardened);
313
+ setSecurityItem('updates', data.autoUpdates?.installed, data.autoUpdates?.installed);
314
+ setSecurityItem('tailscale', data.tailscale?.installed, data.tailscale?.installed);
315
+
316
+ // Update score badge
317
+ const score = data.score || { passed: 0, total: 6 };
318
+ const badge = document.getElementById('security-badge');
319
+ const quickStatus = document.getElementById('security-quick-status');
320
+
321
+ if (score.passed === score.total) {
322
+ badge.textContent = score.passed + '/' + score.total + ' ✓';
323
+ badge.className = 'card-header-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400';
324
+ if (quickStatus) {
325
+ quickStatus.textContent = score.passed + '/' + score.total;
326
+ quickStatus.className = 'font-semibold text-lg text-green-400';
327
+ }
328
+ } else if (score.passed >= Math.ceil(score.total / 2)) {
329
+ badge.textContent = score.passed + '/' + score.total;
330
+ badge.className = 'card-header-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/20 text-yellow-400';
331
+ if (quickStatus) {
332
+ quickStatus.textContent = score.passed + '/' + score.total;
333
+ quickStatus.className = 'font-semibold text-lg text-yellow-400';
334
+ }
335
+ } else {
336
+ badge.textContent = score.passed + '/' + score.total;
337
+ badge.className = 'card-header-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-500/20 text-red-400';
338
+ if (quickStatus) {
339
+ quickStatus.textContent = score.passed + '/' + score.total;
340
+ quickStatus.className = 'font-semibold text-lg text-red-400';
341
+ }
342
+ }
343
+
344
+ // Show/hide harden button
345
+ const hardenSection = document.getElementById('security-harden-section');
346
+ const resultSection = document.getElementById('security-harden-result');
347
+ if (score.passed < score.total) {
348
+ hardenSection.classList.remove('hidden');
349
+ } else {
350
+ hardenSection.classList.add('hidden');
351
+ resultSection.classList.add('hidden');
352
+ }
353
+ }
354
+
355
+ function setSecurityItem(name, active, installed) {
356
+ const icon = document.getElementById('sec-' + name + '-icon');
357
+ const badge = document.getElementById('sec-' + name + '-badge');
358
+ if (!icon || !badge) return;
359
+
360
+ if (active) {
361
+ icon.textContent = '✓';
362
+ icon.className = 'text-lg text-green-400';
363
+ badge.textContent = 'Active';
364
+ badge.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-500/20 text-green-400';
365
+ } else if (installed) {
366
+ icon.textContent = '⚠';
367
+ icon.className = 'text-lg text-yellow-400';
368
+ badge.textContent = 'Inactive';
369
+ badge.className = 'text-xs font-medium px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-400';
370
+ } else {
371
+ icon.textContent = '✗';
372
+ icon.className = 'text-lg text-red-400';
373
+ badge.textContent = 'Missing';
374
+ badge.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-500/20 text-red-400';
375
+ }
376
+ }
377
+
378
+ // ─── Harden Server ───────────────────────────────────────────
379
+
380
+ async function hardenServer() {
381
+ const btn = document.getElementById('btn-harden');
382
+ const hardenSection = document.getElementById('security-harden-section');
383
+ const progressSection = document.getElementById('security-harden-progress');
384
+ const resultSection = document.getElementById('security-harden-result');
385
+
386
+ btn.disabled = true;
387
+ hardenSection.classList.add('hidden');
388
+ progressSection.classList.remove('hidden');
389
+ resultSection.classList.add('hidden');
390
+
391
+ try {
392
+ const res = await fetch('/api/security/harden', {
393
+ method: 'POST',
394
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
395
+ body: JSON.stringify({ installTailscale: true })
396
+ });
397
+ const data = await res.json();
398
+
399
+ progressSection.classList.add('hidden');
400
+ resultSection.classList.remove('hidden');
401
+
402
+ const resultBox = document.getElementById('harden-result-box');
403
+ const resultTitle = document.getElementById('harden-result-title');
404
+ const resultText = document.getElementById('harden-result-text');
405
+
406
+ if (data.success) {
407
+ resultBox.className = 'rounded-lg p-4 bg-green-500/10 border border-green-500/20';
408
+ resultTitle.textContent = '✓ Server hardened successfully';
409
+ resultTitle.className = 'text-sm font-medium text-green-400';
410
+ resultText.textContent = data.summary;
411
+ resultText.className = 'text-xs mt-1 text-lobsta-muted';
412
+ showToast('Server hardened!', 'success');
413
+ } else {
414
+ const failedSteps = (data.results || []).filter(function(r) { return !r.success; });
415
+ resultBox.className = 'rounded-lg p-4 bg-yellow-500/10 border border-yellow-500/20';
416
+ resultTitle.textContent = '⚠ Partially hardened';
417
+ resultTitle.className = 'text-sm font-medium text-yellow-400';
418
+ var detail = data.summary || '';
419
+ if (failedSteps.length > 0) {
420
+ detail += ' — Failed: ' + failedSteps.map(function(s) { return s.step; }).join(', ');
421
+ }
422
+ resultText.textContent = detail;
423
+ resultText.className = 'text-xs mt-1 text-lobsta-muted';
424
+ showToast(data.summary, 'warning');
425
+ }
426
+
427
+ // Refresh both
428
+ await refreshSecurityStatus();
429
+ await refreshTailscaleStatus();
430
+ } catch (err) {
431
+ progressSection.classList.add('hidden');
432
+ hardenSection.classList.remove('hidden');
433
+ btn.disabled = false;
434
+ showToast(err.message || 'Hardening failed', 'error');
435
+ }
436
+ }
437
+
438
+ // ─── Tailscale ───────────────────────────────────────────────
439
+
440
+ async function refreshTailscaleStatus() {
441
+ try {
442
+ const res = await fetch('/api/tailscale/status', { headers: authHeaders() });
443
+ const data = await res.json();
444
+ updateTailscaleUI(data);
445
+ // Also refresh gateway info when tailscale status changes
446
+ refreshGatewayInfo();
447
+ } catch (err) {
448
+ console.error('Failed to fetch Tailscale status:', err);
449
+ updateTailscaleUI({ installed: false, connected: false, status: 'error', message: 'Failed to check' });
450
+ }
451
+ }
452
+
453
+ var _tailscaleData = null;
454
+
455
+ function updateTailscaleUI(data) {
456
+ var statusText = document.getElementById('tailscale-status-text');
457
+ var statusDetail = document.getElementById('tailscale-status-detail');
458
+ var connectedSection = document.getElementById('tailscale-connected');
459
+ var connectForm = document.getElementById('tailscale-connect-form');
460
+ var notInstalledSection = document.getElementById('tailscale-not-installed');
461
+
462
+ // Store tailscale data globally for webchat section
463
+ _tailscaleData = data;
464
+
465
+ if (data.connected) {
466
+ statusText.textContent = 'Tailscale VPN is active';
467
+ statusDetail.textContent = data.message || '';
468
+ connectedSection.classList.remove('hidden');
469
+ connectForm.classList.add('hidden');
470
+ notInstalledSection.classList.add('hidden');
471
+ document.getElementById('tailscale-hostname').textContent = data.hostname || '—';
472
+ document.getElementById('tailscale-ip').textContent = (data.tailscaleIPs && data.tailscaleIPs[0]) || '—';
473
+ } else if (data.installed) {
474
+ statusText.textContent = 'Tailscale installed — not connected';
475
+ statusDetail.textContent = 'Enter an auth key below to connect';
476
+ connectedSection.classList.add('hidden');
477
+ connectForm.classList.remove('hidden');
478
+ notInstalledSection.classList.add('hidden');
479
+ } else {
480
+ statusText.textContent = 'Tailscale is not installed';
481
+ statusDetail.textContent = data.status === 'error' ? 'Could not check status' : 'Install below or use Harden Server';
482
+ connectedSection.classList.add('hidden');
483
+ connectForm.classList.add('hidden');
484
+ notInstalledSection.classList.remove('hidden');
485
+ }
486
+
487
+ // Update webchat section based on tailscale + serve state
488
+ updateWebchatSection();
489
+ }
490
+
491
+ async function installTailscale() {
492
+ var btn = document.getElementById('btn-ts-install');
493
+ btn.disabled = true;
494
+ btn.textContent = '📦 Installing...';
495
+
496
+ try {
497
+ var res = await fetch('/api/tailscale/install', authFetchOpts({ method: 'POST' }));
498
+ var data = await res.json();
499
+
500
+ if (!res.ok) {
501
+ throw new Error(data.error || 'Install failed');
502
+ }
503
+
504
+ showToast(data.message || 'Tailscale installed!', 'success');
505
+ await refreshTailscaleStatus();
506
+ await refreshSecurityStatus();
507
+ } catch (err) {
508
+ showToast(err.message || 'Install failed', 'error');
509
+ } finally {
510
+ btn.disabled = false;
511
+ btn.textContent = '📦 Install Tailscale';
512
+ }
513
+ }
514
+
515
+ async function connectTailscale() {
516
+ var keyInput = document.getElementById('tailscale-auth-key');
517
+ var errorEl = document.getElementById('tailscale-key-error');
518
+ var btn = document.getElementById('btn-ts-connect');
519
+ var authKey = keyInput.value.trim();
520
+
521
+ // Clear previous errors
522
+ errorEl.classList.add('hidden');
523
+ errorEl.textContent = '';
524
+
525
+ if (!authKey) {
526
+ errorEl.textContent = 'Auth key is required';
527
+ errorEl.classList.remove('hidden');
528
+ return;
529
+ }
530
+
531
+ if (!authKey.startsWith('tskey-')) {
532
+ errorEl.textContent = 'Invalid key — Tailscale auth keys start with tskey-auth-';
533
+ errorEl.classList.remove('hidden');
534
+ return;
535
+ }
536
+
537
+ btn.disabled = true;
538
+ btn.textContent = '🔗 Connecting...';
539
+
540
+ try {
541
+ var res = await fetch('/api/tailscale/connect', {
542
+ method: 'POST',
543
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
544
+ body: JSON.stringify({ authKey: authKey })
545
+ });
546
+
547
+ var data = await res.json();
548
+
549
+ if (!res.ok) {
550
+ throw new Error(data.error || 'Connection failed');
551
+ }
552
+
553
+ showToast(data.message || 'Connected to Tailscale!', 'success');
554
+ keyInput.value = '';
555
+
556
+ // If the response includes serve data, update webchat immediately
557
+ if (data.serve) {
558
+ _tailscaleData = {
559
+ connected: data.connected,
560
+ hostname: data.hostname,
561
+ tailscaleIPs: data.tailscaleIPs,
562
+ serve: {
563
+ active: data.serve.enabled,
564
+ dnsName: data.dnsName,
565
+ url: data.serve.url,
566
+ webchatUrl: data.serve.webchatUrl,
567
+ enableUrl: data.serve.enableUrl
568
+ }
569
+ };
570
+ updateWebchatSection();
571
+
572
+ if (data.serve.enabled) {
573
+ showToast('Tailscale Serve configured — Web Chat is ready!', 'success');
574
+ } else if (data.serve.enableUrl) {
575
+ showToast('Tailscale Serve needs to be enabled on your tailnet', 'warning');
576
+ }
577
+ }
578
+
579
+ await refreshTailscaleStatus();
580
+ await refreshSecurityStatus();
581
+ } catch (err) {
582
+ showToast(err.message || 'Failed to connect', 'error');
583
+ errorEl.textContent = err.message;
584
+ errorEl.classList.remove('hidden');
585
+ } finally {
586
+ btn.disabled = false;
587
+ btn.textContent = '🔗 Connect to Tailscale';
588
+ }
589
+ }
590
+
591
+ async function disconnectTailscale() {
592
+ var btn = document.getElementById('btn-ts-disconnect');
593
+ btn.disabled = true;
594
+ btn.textContent = 'Disconnecting...';
595
+
596
+ try {
597
+ var res = await fetch('/api/tailscale/disconnect', authFetchOpts({ method: 'POST' }));
598
+ var data = await res.json();
599
+
600
+ if (!res.ok) {
601
+ throw new Error(data.error || 'Disconnect failed');
602
+ }
603
+
604
+ showToast('Tailscale disconnected', 'success');
605
+ await refreshTailscaleStatus();
606
+ await refreshSecurityStatus();
607
+ } catch (err) {
608
+ showToast(err.message || 'Failed to disconnect', 'error');
609
+ } finally {
610
+ btn.disabled = false;
611
+ btn.textContent = 'Disconnect Tailscale';
612
+ }
613
+ }
614
+
615
+ // ─── Channels Management ─────────────────────────────────────
616
+
617
+ async function refreshChannels() {
618
+ try {
619
+ const res = await fetch('/api/channels', { headers: authHeaders() });
620
+ const data = await res.json();
621
+ renderChannels(data.channels || []);
622
+ } catch (err) {
623
+ console.error('Failed to fetch channels:', err);
624
+ const list = document.getElementById('channels-list');
625
+ if (list) list.innerHTML = '<p class="text-sm text-lobsta-muted">Failed to load channels</p>';
626
+ }
627
+ }
628
+
629
+ function renderChannels(channels) {
630
+ const list = document.getElementById('channels-list');
631
+ if (!list) return;
632
+
633
+ let html = '';
634
+ channels.forEach(ch => {
635
+ const statusClass = ch.configured ? 'connected' : 'not-configured';
636
+ const statusText = ch.configured ? '✅ Connected' : '⚪ Not configured';
637
+ const detail = ch.details && ch.details.summary ? ch.details.summary : '';
638
+
639
+ html += '<div class="channel-row">';
640
+ html += ' <div class="channel-row-info">';
641
+ html += ' <span class="channel-row-icon">' + escapeHtml(ch.icon) + '</span>';
642
+ html += ' <div>';
643
+ html += ' <div class="channel-row-name">' + escapeHtml(ch.name) + '</div>';
644
+ if (detail) {
645
+ html += ' <div class="channel-row-detail">' + escapeHtml(detail) + '</div>';
646
+ }
647
+ html += ' </div>';
648
+ html += ' </div>';
649
+ html += ' <div class="channel-row-status">';
650
+ html += ' <span class="channel-status-badge ' + statusClass + '">' + statusText + '</span>';
651
+ html += ' </div>';
652
+ html += ' <div class="channel-row-actions">';
653
+ if (ch.id === 'web') {
654
+ // Web is always active, no actions
655
+ } else if (ch.configured) {
656
+ html += ' <button onclick="removeChannel(\'' + ch.id + '\')" class="btn btn-sm btn-secondary text-xs">Remove</button>';
657
+ } else {
658
+ html += ' <button onclick="addChannel(\'' + ch.id + '\')" class="btn btn-sm btn-primary text-xs">Add</button>';
659
+ }
660
+ html += ' </div>';
661
+ html += '</div>';
662
+ });
663
+
664
+ list.innerHTML = html || '<p class="text-sm text-lobsta-muted">No channels found</p>';
665
+ }
666
+
667
+ function addChannel(type) {
668
+ // Hide all forms first
669
+ document.querySelectorAll('.channel-inline-form').forEach(el => el.classList.add('hidden'));
670
+ // Show the relevant form
671
+ const form = document.getElementById('channel-form-' + type);
672
+ if (form) form.classList.remove('hidden');
673
+ }
674
+
675
+ function cancelChannelForm(type) {
676
+ const form = document.getElementById('channel-form-' + type);
677
+ if (form) form.classList.add('hidden');
678
+ }
679
+
680
+ async function saveChannel(type) {
681
+ let payload = {};
682
+ let valid = true;
683
+
684
+ if (type === 'telegram') {
685
+ const botToken = document.getElementById('manage-tg-bot-token').value.trim();
686
+ const userId = document.getElementById('manage-tg-user-id').value.trim();
687
+ const tokenErr = document.getElementById('manage-tg-token-error');
688
+ const userErr = document.getElementById('manage-tg-userid-error');
689
+ tokenErr.classList.add('hidden');
690
+ userErr.classList.add('hidden');
691
+
692
+ if (!botToken) {
693
+ tokenErr.textContent = 'Bot token is required';
694
+ tokenErr.classList.remove('hidden');
695
+ valid = false;
696
+ } else if (!/^\d+:[A-Za-z0-9_-]+$/.test(botToken)) {
697
+ tokenErr.textContent = 'Invalid format — should look like 123456789:ABCdef...';
698
+ tokenErr.classList.remove('hidden');
699
+ valid = false;
700
+ }
701
+ if (!userId) {
702
+ userErr.textContent = 'User ID is required';
703
+ userErr.classList.remove('hidden');
704
+ valid = false;
705
+ } else if (!/^\d+$/.test(userId)) {
706
+ userErr.textContent = 'User ID should be a number';
707
+ userErr.classList.remove('hidden');
708
+ valid = false;
709
+ }
710
+ payload = { botToken, userId };
711
+ } else if (type === 'discord') {
712
+ const botToken = document.getElementById('manage-dc-bot-token').value.trim();
713
+ const serverId = document.getElementById('manage-dc-server-id').value.trim();
714
+ const tokenErr = document.getElementById('manage-dc-token-error');
715
+ const serverErr = document.getElementById('manage-dc-serverid-error');
716
+ tokenErr.classList.add('hidden');
717
+ serverErr.classList.add('hidden');
718
+
719
+ if (!botToken) {
720
+ tokenErr.textContent = 'Bot token is required';
721
+ tokenErr.classList.remove('hidden');
722
+ valid = false;
723
+ }
724
+ if (!serverId) {
725
+ serverErr.textContent = 'Server ID is required';
726
+ serverErr.classList.remove('hidden');
727
+ valid = false;
728
+ } else if (!/^\d+$/.test(serverId)) {
729
+ serverErr.textContent = 'Server ID should be a number';
730
+ serverErr.classList.remove('hidden');
731
+ valid = false;
732
+ }
733
+ payload = { botToken, serverId };
734
+ }
735
+
736
+ if (!valid) return;
737
+
738
+ const btn = document.getElementById('btn-save-' + type);
739
+ btn.disabled = true;
740
+ btn.textContent = 'Saving...';
741
+
742
+ try {
743
+ const res = await fetch('/api/channels/' + type, {
744
+ method: 'POST',
745
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
746
+ body: JSON.stringify(payload)
747
+ });
748
+ const data = await res.json();
749
+
750
+ if (!res.ok) {
751
+ throw new Error(data.error || 'Failed to save channel');
752
+ }
753
+
754
+ showToast(data.message || type + ' channel configured!', 'success');
755
+ cancelChannelForm(type);
756
+ await refreshChannels();
757
+ } catch (err) {
758
+ showToast(err.message || 'Failed to save', 'error');
759
+ } finally {
760
+ btn.disabled = false;
761
+ btn.textContent = 'Save & Connect';
762
+ }
763
+ }
764
+
765
+ async function removeChannel(type) {
766
+ if (!confirm('Remove ' + capitalize(type) + ' channel? The gateway will restart.')) return;
767
+
768
+ try {
769
+ const res = await fetch('/api/channels/' + type, authFetchOpts({ method: 'DELETE' }));
770
+ const data = await res.json();
771
+
772
+ if (!res.ok) {
773
+ throw new Error(data.error || 'Failed to remove channel');
774
+ }
775
+
776
+ showToast(data.message || type + ' channel removed', 'success');
777
+ await refreshChannels();
778
+ } catch (err) {
779
+ showToast(err.message || 'Failed to remove', 'error');
780
+ }
781
+ }
782
+
783
+ // ─── Private Memory ──────────────────────────────────────────
784
+
785
+ async function refreshMemoryStatus() {
786
+ try {
787
+ const res = await fetch('/api/memory/status', { headers: authHeaders() });
788
+ const data = await res.json();
789
+ updateMemoryUI(data);
790
+ } catch (err) {
791
+ console.error('Failed to fetch memory status:', err);
792
+ }
793
+ }
794
+
795
+ function updateMemoryUI(data) {
796
+ var toggle = document.getElementById('memory-toggle');
797
+ var badge = document.getElementById('memory-badge');
798
+ var providerText = document.getElementById('memory-provider-text');
799
+ var modelText = document.getElementById('memory-model-text');
800
+ var downloadText = document.getElementById('memory-download-text');
801
+ var modelRow = document.getElementById('memory-model-row');
802
+ var downloadRow = document.getElementById('memory-download-row');
803
+ var noteEl = document.getElementById('memory-note');
804
+ var downloadAction = document.getElementById('memory-download-action');
805
+
806
+ if (!toggle) return;
807
+
808
+ // Set toggle state (avoid triggering onchange)
809
+ toggle.checked = data.isPrivate;
810
+
811
+ if (data.isPrivate) {
812
+ // Private / local mode
813
+ badge.textContent = '🔒 Private';
814
+ badge.className = 'card-header-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400';
815
+
816
+ providerText.textContent = 'Local (on-device)';
817
+ modelText.textContent = data.modelName + ' (' + data.modelSize + ')';
818
+ modelRow.classList.remove('hidden');
819
+
820
+ if (data.modelDownloaded) {
821
+ downloadText.textContent = '✅ Downloaded (' + data.modelSize + ')';
822
+ downloadText.className = 'text-green-400';
823
+ noteEl.innerHTML = '<p class="text-xs text-green-400/80">🔒 All memory data stays on your server. Zero external API calls.</p>';
824
+ if (downloadAction) downloadAction.classList.add('hidden');
825
+ } else {
826
+ downloadText.textContent = '⏳ Not yet downloaded';
827
+ downloadText.className = 'text-yellow-400';
828
+ noteEl.innerHTML = '<p class="text-xs text-lobsta-muted">💡 Model (~313MB) downloads on first use, or download now. Requires ~512MB RAM.</p>';
829
+ if (downloadAction && !_downloadPolling) {
830
+ downloadAction.classList.remove('hidden');
831
+ var idleEl = document.getElementById('memory-download-idle');
832
+ var progressEl = document.getElementById('memory-download-progress');
833
+ var completeEl = document.getElementById('memory-download-complete');
834
+ var errorEl = document.getElementById('memory-download-error');
835
+ if (idleEl) idleEl.classList.remove('hidden');
836
+ if (progressEl) progressEl.classList.add('hidden');
837
+ if (completeEl) completeEl.classList.add('hidden');
838
+ if (errorEl) errorEl.classList.add('hidden');
839
+ }
840
+ }
841
+ downloadRow.classList.remove('hidden');
842
+ } else {
843
+ // Cloud / remote mode
844
+ badge.textContent = '☁️ Cloud';
845
+ badge.className = 'card-header-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/20 text-yellow-400';
846
+
847
+ var providerLabel = data.provider === 'openai' ? 'OpenAI' : data.provider === 'auto' ? 'Auto-detect (cloud)' : capitalize(data.provider);
848
+ providerText.textContent = providerLabel;
849
+ modelRow.classList.add('hidden');
850
+ downloadRow.classList.add('hidden');
851
+ if (downloadAction) downloadAction.classList.add('hidden');
852
+ noteEl.innerHTML = '<p class="text-xs text-lobsta-muted">☁️ Memory embeddings are sent to cloud APIs. Enable Private Memory to keep data on-device.</p>';
853
+ }
854
+ }
855
+
856
+ var _memoryToggling = false;
857
+
858
+ async function handleMemoryToggle(checked) {
859
+ if (_memoryToggling) return;
860
+ _memoryToggling = true;
861
+
862
+ var toggle = document.getElementById('memory-toggle');
863
+ var mode = checked ? 'local' : 'remote';
864
+
865
+ toggle.disabled = true;
866
+ showToast('Switching to ' + (checked ? 'private' : 'cloud') + ' memory...', 'info');
867
+
868
+ try {
869
+ var res = await fetch('/api/memory/toggle', {
870
+ method: 'POST',
871
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
872
+ body: JSON.stringify({ mode: mode })
873
+ });
874
+ var data = await res.json();
875
+
876
+ if (!res.ok) {
877
+ throw new Error(data.error || 'Failed to toggle memory');
878
+ }
879
+
880
+ showToast(data.message || 'Memory mode updated', 'success');
881
+ await refreshMemoryStatus();
882
+ } catch (err) {
883
+ showToast(err.message || 'Failed to toggle memory', 'error');
884
+ // Revert toggle
885
+ toggle.checked = !checked;
886
+ } finally {
887
+ toggle.disabled = false;
888
+ _memoryToggling = false;
889
+ }
890
+ }
891
+
892
+ // ─── Model Download ──────────────────────────────────────────
893
+
894
+ var _downloadPolling = false;
895
+
896
+ async function downloadModel() {
897
+ var idleEl = document.getElementById('memory-download-idle');
898
+ var progressEl = document.getElementById('memory-download-progress');
899
+ var completeEl = document.getElementById('memory-download-complete');
900
+ var errorEl = document.getElementById('memory-download-error');
901
+ var actionEl = document.getElementById('memory-download-action');
902
+
903
+ // Show progress, hide others
904
+ if (actionEl) actionEl.classList.remove('hidden');
905
+ if (idleEl) idleEl.classList.add('hidden');
906
+ if (completeEl) completeEl.classList.add('hidden');
907
+ if (errorEl) errorEl.classList.add('hidden');
908
+ if (progressEl) progressEl.classList.remove('hidden');
909
+
910
+ // Reset progress bar
911
+ var progressFill = document.getElementById('memory-download-progress-fill');
912
+ if (progressFill) progressFill.style.width = '0%';
913
+ var progressText = document.getElementById('memory-download-progress-text');
914
+ if (progressText) progressText.textContent = 'Starting download...';
915
+
916
+ try {
917
+ var res = await fetch('/api/memory/download', authFetchOpts({ method: 'POST' }));
918
+ var data = await res.json();
919
+
920
+ if (data.status === 'already_downloaded') {
921
+ showDownloadComplete(data.size);
922
+ refreshMemoryStatus();
923
+ return;
924
+ }
925
+
926
+ // Start polling for progress
927
+ pollDownloadStatus();
928
+ } catch (err) {
929
+ showDownloadError(err.message || 'Failed to start download');
930
+ }
931
+ }
932
+
933
+ function pollDownloadStatus() {
934
+ if (_downloadPolling) return;
935
+ _downloadPolling = true;
936
+
937
+ var interval = setInterval(async function() {
938
+ try {
939
+ var res = await fetch('/api/memory/download/status', { headers: authHeaders() });
940
+ var data = await res.json();
941
+
942
+ if (data.status === 'downloaded') {
943
+ clearInterval(interval);
944
+ _downloadPolling = false;
945
+ showDownloadComplete(data.size);
946
+ refreshMemoryStatus();
947
+ return;
948
+ }
949
+
950
+ if (data.status === 'downloading') {
951
+ updateDownloadProgress(data.progress || 0, data.downloaded, data.total);
952
+ return;
953
+ }
954
+
955
+ // not_downloaded — download hasn't started writing yet, keep waiting
956
+ if (data.status === 'not_downloaded') {
957
+ updateDownloadProgress(0, 0, 0);
958
+ return;
959
+ }
960
+ } catch (err) {
961
+ // Network error — keep trying
962
+ }
963
+ }, 2000);
964
+
965
+ // Timeout after 10 minutes
966
+ setTimeout(function() {
967
+ if (_downloadPolling) {
968
+ clearInterval(interval);
969
+ _downloadPolling = false;
970
+ showDownloadError('Download timed out. Try again.');
971
+ }
972
+ }, 600000);
973
+ }
974
+
975
+ function updateDownloadProgress(percent, downloaded, total) {
976
+ var progressText = document.getElementById('memory-download-progress-text');
977
+ var progressFill = document.getElementById('memory-download-progress-fill');
978
+
979
+ if (progressFill) progressFill.style.width = Math.min(percent, 99) + '%';
980
+
981
+ if (progressText) {
982
+ if (downloaded && total) {
983
+ var dlMB = (downloaded / 1024 / 1024).toFixed(0);
984
+ var totalMB = (total / 1024 / 1024).toFixed(0);
985
+ progressText.textContent = dlMB + 'MB / ' + totalMB + 'MB (' + percent + '%)';
986
+ } else if (percent > 0) {
987
+ progressText.textContent = percent + '% complete';
988
+ } else {
989
+ progressText.textContent = 'Starting download...';
990
+ }
991
+ }
992
+ }
993
+
994
+ function showDownloadComplete(size) {
995
+ var idleEl = document.getElementById('memory-download-idle');
996
+ var progressEl = document.getElementById('memory-download-progress');
997
+ var completeEl = document.getElementById('memory-download-complete');
998
+ var errorEl = document.getElementById('memory-download-error');
999
+ var sizeText = document.getElementById('memory-download-size-text');
1000
+
1001
+ if (idleEl) idleEl.classList.add('hidden');
1002
+ if (progressEl) progressEl.classList.add('hidden');
1003
+ if (errorEl) errorEl.classList.add('hidden');
1004
+ if (completeEl) completeEl.classList.remove('hidden');
1005
+
1006
+ if (sizeText && size) {
1007
+ sizeText.textContent = (size / 1024 / 1024).toFixed(0) + 'MB on disk';
1008
+ }
1009
+
1010
+ showToast('Model downloaded successfully!', 'success');
1011
+ }
1012
+
1013
+ function showDownloadError(msg) {
1014
+ var idleEl = document.getElementById('memory-download-idle');
1015
+ var progressEl = document.getElementById('memory-download-progress');
1016
+ var completeEl = document.getElementById('memory-download-complete');
1017
+ var errorEl = document.getElementById('memory-download-error');
1018
+ var errorText = document.getElementById('memory-download-error-text');
1019
+
1020
+ if (idleEl) idleEl.classList.add('hidden');
1021
+ if (progressEl) progressEl.classList.add('hidden');
1022
+ if (completeEl) completeEl.classList.add('hidden');
1023
+ if (errorEl) errorEl.classList.remove('hidden');
1024
+
1025
+ if (errorText) errorText.textContent = '❌ ' + msg;
1026
+
1027
+ showToast(msg, 'error');
1028
+ }
1029
+
1030
+ // ─── Gateway Info / Web Chat ─────────────────────────────────
1031
+
1032
+ var _gatewayInfo = null;
1033
+ var _gatewayTokenVisible = false;
1034
+
1035
+ async function refreshGatewayInfo() {
1036
+ try {
1037
+ var res = await fetch('/api/gateway/info', { headers: authHeaders() });
1038
+ var data = await res.json();
1039
+ _gatewayInfo = data;
1040
+ updateWebChatUI(data);
1041
+ } catch (err) {
1042
+ console.error('Failed to fetch gateway info:', err);
1043
+ _gatewayInfo = null;
1044
+ updateWebChatUI(null);
1045
+ }
1046
+ }
1047
+
1048
+ function updateWebChatUI(data) {
1049
+ // Gateway info loaded — trigger webchat section update
1050
+ updateWebchatSection();
1051
+ }
1052
+
1053
+ function updateWebchatSection() {
1054
+ var loadingEl = document.getElementById('webchat-loading');
1055
+ var readyEl = document.getElementById('webchat-ready');
1056
+ var setupEl = document.getElementById('webchat-setup-needed');
1057
+ var needsTsEl = document.getElementById('webchat-needs-tailscale');
1058
+
1059
+ if (!loadingEl) return;
1060
+
1061
+ // Hide all states
1062
+ loadingEl.classList.add('hidden');
1063
+ if (readyEl) readyEl.classList.add('hidden');
1064
+ if (setupEl) setupEl.classList.add('hidden');
1065
+ if (needsTsEl) needsTsEl.classList.add('hidden');
1066
+
1067
+ var tsData = _tailscaleData;
1068
+
1069
+ if (!tsData || !tsData.connected) {
1070
+ // Tailscale not connected
1071
+ if (needsTsEl) needsTsEl.classList.remove('hidden');
1072
+ return;
1073
+ }
1074
+
1075
+ var serve = tsData.serve;
1076
+
1077
+ if (serve && serve.active) {
1078
+ // Serve is active — show ready state
1079
+ if (readyEl) readyEl.classList.remove('hidden');
1080
+
1081
+ var urlEl = document.getElementById('webchat-url');
1082
+ var linkEl = document.getElementById('webchat-open-link');
1083
+ var tokenSection = document.getElementById('webchat-token-section');
1084
+
1085
+ var webchatUrl = serve.webchatUrl || serve.url || '';
1086
+ var displayUrl = serve.url || '';
1087
+
1088
+ if (urlEl) urlEl.textContent = displayUrl;
1089
+ if (linkEl) linkEl.href = webchatUrl;
1090
+
1091
+ // Show token section if we have gateway info with token
1092
+ if (_gatewayInfo && _gatewayInfo.token && tokenSection) {
1093
+ tokenSection.classList.remove('hidden');
1094
+ updateTokenDisplay();
1095
+ } else if (tokenSection) {
1096
+ tokenSection.classList.add('hidden');
1097
+ }
1098
+ } else {
1099
+ // Serve not active — show setup needed
1100
+ if (setupEl) setupEl.classList.remove('hidden');
1101
+
1102
+ var enableLinkEl = document.getElementById('webchat-serve-enable-link');
1103
+ if (enableLinkEl && serve && serve.enableUrl) {
1104
+ enableLinkEl.href = serve.enableUrl;
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ function updateTokenDisplay() {
1110
+ var displayEl = document.getElementById('webchat-token-display');
1111
+ if (!displayEl || !_gatewayInfo || !_gatewayInfo.token) return;
1112
+
1113
+ if (_gatewayTokenVisible) {
1114
+ displayEl.textContent = _gatewayInfo.token;
1115
+ } else {
1116
+ var token = _gatewayInfo.token;
1117
+ var lastChars = token.slice(-8);
1118
+ displayEl.textContent = '••••••••' + lastChars;
1119
+ }
1120
+ }
1121
+
1122
+ function toggleGatewayToken() {
1123
+ _gatewayTokenVisible = !_gatewayTokenVisible;
1124
+ updateTokenDisplay();
1125
+ var btn = document.getElementById('btn-toggle-token');
1126
+ if (btn) btn.textContent = _gatewayTokenVisible ? '🙈' : '👁️';
1127
+ }
1128
+
1129
+ function copyGatewayToken() {
1130
+ if (!_gatewayInfo || !_gatewayInfo.token) {
1131
+ showToast('No token available', 'error');
1132
+ return;
1133
+ }
1134
+ navigator.clipboard.writeText(_gatewayInfo.token).then(function() {
1135
+ showToast('Gateway token copied!', 'success');
1136
+ var btn = document.getElementById('btn-copy-token');
1137
+ if (btn) {
1138
+ btn.textContent = '✅';
1139
+ setTimeout(function() { btn.textContent = '📋'; }, 1500);
1140
+ }
1141
+ }).catch(function() {
1142
+ // Fallback for non-HTTPS
1143
+ var textarea = document.createElement('textarea');
1144
+ textarea.value = _gatewayInfo.token;
1145
+ textarea.style.position = 'fixed';
1146
+ textarea.style.opacity = '0';
1147
+ document.body.appendChild(textarea);
1148
+ textarea.select();
1149
+ try {
1150
+ document.execCommand('copy');
1151
+ showToast('Gateway token copied!', 'success');
1152
+ } catch (e) {
1153
+ showToast('Copy failed — select and copy manually', 'error');
1154
+ }
1155
+ document.body.removeChild(textarea);
1156
+ });
1157
+ }
1158
+
1159
+ function openWebChat() {
1160
+ // Prefer HTTPS serve URL from tailscale data
1161
+ if (_tailscaleData && _tailscaleData.serve && _tailscaleData.serve.webchatUrl) {
1162
+ window.open(_tailscaleData.serve.webchatUrl, '_blank');
1163
+ } else if (_tailscaleData && _tailscaleData.serve && _tailscaleData.serve.url) {
1164
+ window.open(_tailscaleData.serve.url, '_blank');
1165
+ } else if (_gatewayInfo && _gatewayInfo.tailscaleUrl) {
1166
+ window.open(_gatewayInfo.tailscaleUrl, '_blank');
1167
+ } else if (_gatewayInfo && _gatewayInfo.localUrl) {
1168
+ window.open(_gatewayInfo.localUrl, '_blank');
1169
+ } else {
1170
+ window.open('http://localhost:18789/', '_blank');
1171
+ }
1172
+ }
1173
+
1174
+ // ─── Web Chat: Retry Serve Setup ─────────────────────────────
1175
+
1176
+ async function retryServeSetup() {
1177
+ var btn = document.getElementById('btn-retry-serve');
1178
+ if (!btn) return;
1179
+ btn.disabled = true;
1180
+ btn.textContent = 'Setting up...';
1181
+
1182
+ try {
1183
+ var res = await fetch('/api/tailscale/setup-serve', authFetchOpts({ method: 'POST' }));
1184
+ var data = await res.json();
1185
+
1186
+ if (data.status === 'ok') {
1187
+ // Update tailscale data with serve info
1188
+ if (_tailscaleData) {
1189
+ _tailscaleData.serve = {
1190
+ active: true,
1191
+ dnsName: _tailscaleData.serve ? _tailscaleData.serve.dnsName : null,
1192
+ url: data.url,
1193
+ webchatUrl: data.webchatUrl
1194
+ };
1195
+ }
1196
+ updateWebchatSection();
1197
+ showToast('Tailscale Serve configured — Web Chat is ready!', 'success');
1198
+ } else if (data.status === 'not_enabled') {
1199
+ showToast('Tailscale Serve is still not enabled. Please enable it on your tailnet first, then retry.', 'warning');
1200
+ // Update enable link if provided
1201
+ if (data.enableUrl) {
1202
+ var enableLinkEl = document.getElementById('webchat-serve-enable-link');
1203
+ if (enableLinkEl) enableLinkEl.href = data.enableUrl;
1204
+ }
1205
+ } else {
1206
+ showToast(data.error || 'Serve setup failed', 'error');
1207
+ }
1208
+ } catch (e) {
1209
+ console.error('Serve setup failed:', e);
1210
+ showToast('Failed to set up Tailscale Serve', 'error');
1211
+ } finally {
1212
+ btn.disabled = false;
1213
+ btn.textContent = '↻ Retry Setup';
1214
+ }
1215
+ }
1216
+
1217
+ function copyWebchatUrl() {
1218
+ var url = document.getElementById('webchat-url')?.textContent;
1219
+ if (!url || url === '—') {
1220
+ showToast('No URL available', 'error');
1221
+ return;
1222
+ }
1223
+ navigator.clipboard.writeText(url).then(function() {
1224
+ showToast('Web Chat URL copied!', 'success');
1225
+ }).catch(function() {
1226
+ // Fallback for non-HTTPS context
1227
+ var ta = document.createElement('textarea');
1228
+ ta.value = url;
1229
+ ta.style.position = 'fixed';
1230
+ ta.style.opacity = '0';
1231
+ document.body.appendChild(ta);
1232
+ ta.select();
1233
+ try {
1234
+ document.execCommand('copy');
1235
+ showToast('Web Chat URL copied!', 'success');
1236
+ } catch (e) {
1237
+ showToast('Copy failed — select and copy manually', 'error');
1238
+ }
1239
+ document.body.removeChild(ta);
1240
+ });
1241
+ }
1242
+
1243
+ // ─── Why Tailscale? Toggle ───────────────────────────────────
1244
+
1245
+ function toggleWhyTailscale() {
1246
+ var content = document.getElementById('why-tailscale-content');
1247
+ var chevron = document.getElementById('why-ts-chevron');
1248
+ if (!content) return;
1249
+
1250
+ if (content.classList.contains('hidden')) {
1251
+ content.classList.remove('hidden');
1252
+ if (chevron) chevron.classList.add('rotate-180');
1253
+ } else {
1254
+ content.classList.add('hidden');
1255
+ if (chevron) chevron.classList.remove('rotate-180');
1256
+ }
1257
+ }
1258
+
1259
+ // ─── Helpers ─────────────────────────────────────────────────
1260
+
1261
+ function formatModel(model) {
1262
+ if (!model) return null;
1263
+ var labels = {
1264
+ 'anthropic/claude-sonnet-4': 'Claude Sonnet 4',
1265
+ 'anthropic/claude-sonnet-4-5': 'Claude Sonnet 4.5',
1266
+ 'anthropic/claude-opus-4-5': 'Claude Opus 4.5',
1267
+ 'anthropic/claude-haiku-3-5': 'Claude Haiku 3.5'
1268
+ };
1269
+ return labels[model] || model;
1270
+ }
1271
+
1272
+ function capitalize(str) {
1273
+ return str.charAt(0).toUpperCase() + str.slice(1);
1274
+ }