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.
- package/bin/lobstakit.js +2 -0
- package/lib/config.js +176 -0
- package/lib/gateway.js +104 -0
- package/lib/proxy.js +33 -0
- package/package.json +16 -0
- package/public/css/styles.css +579 -0
- package/public/index.html +507 -0
- package/public/js/app.js +198 -0
- package/public/js/login.js +93 -0
- package/public/js/manage.js +1274 -0
- package/public/js/setup.js +755 -0
- package/public/login.html +73 -0
- package/public/manage.html +734 -0
- package/server.js +1357 -0
|
@@ -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
|
+
}
|