cli-tunnel 1.2.0-beta.8 → 1.2.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/README.md +98 -40
- package/dist/index.js +227 -64
- package/dist/redact.d.ts +1 -0
- package/dist/redact.js +26 -0
- package/package.json +7 -5
- package/remote-ui/app.js +472 -26
- package/remote-ui/index.html +10 -10
- package/remote-ui/styles.css +169 -0
package/remote-ui/app.js
CHANGED
|
@@ -45,7 +45,12 @@
|
|
|
45
45
|
const permOverlay = $('#permission-overlay');
|
|
46
46
|
const dashboard = $('#dashboard');
|
|
47
47
|
const termContainer = $('#terminal-container');
|
|
48
|
-
let currentView = 'terminal'; // 'dashboard' or '
|
|
48
|
+
let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
|
|
49
|
+
let cachedSessions = [];
|
|
50
|
+
let gridTerminals = []; // { xterm, fitAddon, ws, session, panel }
|
|
51
|
+
var gridMode = 'thumbnails';
|
|
52
|
+
var focusedIndex = 0;
|
|
53
|
+
var tmuxPreset = 'equal';
|
|
49
54
|
|
|
50
55
|
// ─── xterm.js Terminal ───────────────────────────────────
|
|
51
56
|
let xterm = null;
|
|
@@ -115,7 +120,9 @@
|
|
|
115
120
|
|
|
116
121
|
async function loadSessions() {
|
|
117
122
|
try {
|
|
118
|
-
const
|
|
123
|
+
const tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
124
|
+
const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
|
|
125
|
+
const resp = await fetch('/api/sessions', { headers });
|
|
119
126
|
const data = await resp.json();
|
|
120
127
|
renderDashboard(data.sessions || []);
|
|
121
128
|
} catch (err) {
|
|
@@ -127,41 +134,69 @@
|
|
|
127
134
|
const filtered = showOffline ? sessions : sessions.filter(s => s.online);
|
|
128
135
|
const offlineCount = sessions.filter(s => !s.online).length;
|
|
129
136
|
const onlineCount = sessions.filter(s => s.online).length;
|
|
137
|
+
const connectable = filtered.filter(s => s.online && s.token);
|
|
130
138
|
|
|
131
139
|
let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
|
|
132
140
|
<span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
|
|
133
141
|
<span style="flex:1"></span>
|
|
134
|
-
<button
|
|
135
|
-
|
|
136
|
-
<button
|
|
142
|
+
${connectable.length > 1 ? '<button data-action="grid-view" style="background:none;border:1px solid var(--blue);color:var(--blue);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">⊞ Grid</button>' : ''}
|
|
143
|
+
<button data-action="toggle-offline" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
|
|
144
|
+
${offlineCount > 0 ? '<button data-action="clean-offline" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
|
|
145
|
+
<button data-action="refresh" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
|
|
137
146
|
</div>`;
|
|
138
147
|
|
|
139
148
|
if (filtered.length === 0) {
|
|
140
149
|
html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
|
|
141
|
-
(sessions.length === 0 ? 'No
|
|
150
|
+
(sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
|
|
142
151
|
'</div>';
|
|
143
152
|
} else {
|
|
144
|
-
html += filtered.map(s =>
|
|
145
|
-
|
|
153
|
+
html += filtered.map(s => {
|
|
154
|
+
const hasAccess = s.hasToken;
|
|
155
|
+
return `
|
|
156
|
+
<div class="session-card" ${s.online && hasAccess ? 'data-session-port="' + s.port + '" data-session-base-url="' + escapeHtml(s.url) + '"' : ''}>
|
|
146
157
|
<span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
|
|
147
158
|
<div class="info">
|
|
159
|
+
<div class="session-name">${escapeHtml(s.name)}</div>
|
|
148
160
|
<div class="repo">📦 ${escapeHtml(s.repo)}</div>
|
|
149
161
|
<div class="branch">🌿 ${escapeHtml(s.branch)}</div>
|
|
150
|
-
<div class="machine">💻 ${escapeHtml(s.machine)}</div>
|
|
162
|
+
<div class="machine">💻 ${escapeHtml(s.machine)}${!hasAccess && s.online ? ' 🔒' : ''}</div>
|
|
151
163
|
</div>
|
|
152
|
-
${s.online ? '<span class="arrow">→</span>' :
|
|
153
|
-
'<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
${s.online && hasAccess ? '<span class="arrow">→</span>' :
|
|
165
|
+
!s.online ? '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'
|
|
166
|
+
: '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
|
|
167
|
+
</div>`;
|
|
168
|
+
}).join('');
|
|
156
169
|
}
|
|
157
170
|
dashboard.innerHTML = html;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
171
|
+
cachedSessions = sessions;
|
|
172
|
+
// Event delegation
|
|
173
|
+
dashboard.querySelectorAll('.session-card[data-session-port]').forEach(function(card) {
|
|
174
|
+
card.addEventListener('click', function() {
|
|
175
|
+
var port = card.dataset.sessionPort;
|
|
176
|
+
var baseUrl = card.dataset.sessionBaseUrl;
|
|
177
|
+
var tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
178
|
+
var proxyUrl = '/api/proxy/ticket/' + port;
|
|
179
|
+
fetch(proxyUrl, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {}
|
|
182
|
+
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
183
|
+
if (data.ticket) {
|
|
184
|
+
window.location.href = baseUrl + '?ticket=' + encodeURIComponent(data.ticket);
|
|
185
|
+
} else {
|
|
186
|
+
window.location.href = baseUrl;
|
|
187
|
+
}
|
|
188
|
+
}).catch(function() {
|
|
189
|
+
window.location.href = baseUrl;
|
|
190
|
+
});
|
|
191
|
+
});
|
|
161
192
|
});
|
|
162
193
|
dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
|
|
163
194
|
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
|
|
164
195
|
});
|
|
196
|
+
dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
|
|
197
|
+
dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
|
|
198
|
+
dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
|
|
199
|
+
dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
|
|
165
200
|
}
|
|
166
201
|
|
|
167
202
|
window.openSession = (url) => {
|
|
@@ -174,21 +209,404 @@
|
|
|
174
209
|
};
|
|
175
210
|
|
|
176
211
|
window.cleanOffline = async () => {
|
|
177
|
-
const
|
|
212
|
+
const tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
213
|
+
const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
|
|
214
|
+
const resp = await fetch('/api/sessions', { headers });
|
|
178
215
|
const data = await resp.json();
|
|
179
216
|
const offline = (data.sessions || []).filter(s => !s.online);
|
|
180
217
|
for (const s of offline) {
|
|
181
|
-
await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
|
|
218
|
+
await fetch('/api/sessions/' + s.id, { method: 'DELETE', headers });
|
|
182
219
|
}
|
|
183
220
|
loadSessions();
|
|
184
221
|
};
|
|
185
222
|
|
|
186
223
|
window.deleteSession = async (id) => {
|
|
187
|
-
|
|
224
|
+
const tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
225
|
+
const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
|
|
226
|
+
await fetch('/api/sessions/' + id, { method: 'DELETE', headers });
|
|
188
227
|
loadSessions();
|
|
189
228
|
};
|
|
190
229
|
|
|
230
|
+
// ─── Grid View (multi-terminal with layout modes) ───────────
|
|
231
|
+
function showGridView(sessions) {
|
|
232
|
+
var connectable = sessions.filter(function(s) { return s.online && s.token; });
|
|
233
|
+
if (connectable.length === 0) return;
|
|
234
|
+
|
|
235
|
+
// Clean up previous grid
|
|
236
|
+
destroyGrid();
|
|
237
|
+
|
|
238
|
+
currentView = 'grid';
|
|
239
|
+
gridMode = 'thumbnails';
|
|
240
|
+
focusedIndex = 0;
|
|
241
|
+
tmuxPreset = 'equal';
|
|
242
|
+
dashboard.classList.add('hidden');
|
|
243
|
+
terminal.classList.add('hidden');
|
|
244
|
+
termContainer.classList.add('hidden');
|
|
245
|
+
$('#input-area').classList.add('hidden');
|
|
246
|
+
|
|
247
|
+
var gridEl = document.getElementById('grid-view');
|
|
248
|
+
if (!gridEl) {
|
|
249
|
+
gridEl = document.createElement('div');
|
|
250
|
+
gridEl.id = 'grid-view';
|
|
251
|
+
document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
|
|
252
|
+
}
|
|
253
|
+
gridEl.classList.remove('hidden');
|
|
254
|
+
gridEl.innerHTML = '';
|
|
255
|
+
|
|
256
|
+
// ── Toolbar ──
|
|
257
|
+
var toolbar = document.createElement('div');
|
|
258
|
+
toolbar.className = 'grid-toolbar';
|
|
259
|
+
|
|
260
|
+
var modes = [
|
|
261
|
+
{ id: 'thumbnails', label: '\u229E Tiles' },
|
|
262
|
+
{ id: 'tmux', label: '\u229F Tmux' },
|
|
263
|
+
{ id: 'focus', label: '\u25C9 Focus' },
|
|
264
|
+
{ id: 'fullscreen', label: '\u2A21 Full' }
|
|
265
|
+
];
|
|
266
|
+
modes.forEach(function(m) {
|
|
267
|
+
var btn = document.createElement('button');
|
|
268
|
+
btn.textContent = m.label;
|
|
269
|
+
btn.dataset.mode = m.id;
|
|
270
|
+
if (m.id === gridMode) btn.classList.add('active');
|
|
271
|
+
btn.addEventListener('click', function() { switchGridMode(m.id); });
|
|
272
|
+
toolbar.appendChild(btn);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Tmux preset buttons (visible only in tmux mode)
|
|
276
|
+
var presetGroup = document.createElement('span');
|
|
277
|
+
presetGroup.className = 'grid-toolbar-presets hidden';
|
|
278
|
+
presetGroup.id = 'tmux-presets';
|
|
279
|
+
var presets = [
|
|
280
|
+
{ id: 'equal', label: '\u2550 Equal' },
|
|
281
|
+
{ id: 'main-side', label: '\u2590 Main+Side' },
|
|
282
|
+
{ id: 'stacked', label: '\u2261 Stacked' }
|
|
283
|
+
];
|
|
284
|
+
presets.forEach(function(p) {
|
|
285
|
+
var btn = document.createElement('button');
|
|
286
|
+
btn.textContent = p.label;
|
|
287
|
+
btn.dataset.preset = p.id;
|
|
288
|
+
if (p.id === tmuxPreset) btn.classList.add('active');
|
|
289
|
+
btn.addEventListener('click', function() { switchTmuxPreset(p.id); });
|
|
290
|
+
presetGroup.appendChild(btn);
|
|
291
|
+
});
|
|
292
|
+
toolbar.appendChild(presetGroup);
|
|
293
|
+
|
|
294
|
+
var spacer = document.createElement('span');
|
|
295
|
+
spacer.className = 'spacer';
|
|
296
|
+
toolbar.appendChild(spacer);
|
|
297
|
+
|
|
298
|
+
var listBtn = document.createElement('button');
|
|
299
|
+
listBtn.textContent = '\u2190 List';
|
|
300
|
+
listBtn.addEventListener('click', function() {
|
|
301
|
+
destroyGrid();
|
|
302
|
+
currentView = 'dashboard';
|
|
303
|
+
dashboard.classList.remove('hidden');
|
|
304
|
+
if ($('#btn-sessions')) $('#btn-sessions').textContent = 'Terminal';
|
|
305
|
+
loadSessions();
|
|
306
|
+
});
|
|
307
|
+
toolbar.appendChild(listBtn);
|
|
308
|
+
gridEl.appendChild(toolbar);
|
|
309
|
+
|
|
310
|
+
// ── Content container ──
|
|
311
|
+
var contentEl = document.createElement('div');
|
|
312
|
+
contentEl.id = 'grid-content';
|
|
313
|
+
gridEl.appendChild(contentEl);
|
|
314
|
+
|
|
315
|
+
// ── Create panels & connect ──
|
|
316
|
+
connectable.forEach(function(s, index) {
|
|
317
|
+
var panel = document.createElement('div');
|
|
318
|
+
panel.className = 'grid-panel';
|
|
319
|
+
panel.dataset.index = index;
|
|
320
|
+
|
|
321
|
+
var header = document.createElement('div');
|
|
322
|
+
header.className = 'grid-panel-header';
|
|
323
|
+
var nameSpan = document.createElement('span');
|
|
324
|
+
nameSpan.className = 'grid-panel-name';
|
|
325
|
+
nameSpan.textContent = s.name;
|
|
326
|
+
var machineSpan = document.createElement('span');
|
|
327
|
+
machineSpan.className = 'grid-panel-machine';
|
|
328
|
+
machineSpan.textContent = s.machine;
|
|
329
|
+
var statusDot = document.createElement('span');
|
|
330
|
+
statusDot.className = 'grid-panel-status';
|
|
331
|
+
statusDot.textContent = '\u25CF';
|
|
332
|
+
header.appendChild(nameSpan);
|
|
333
|
+
header.appendChild(machineSpan);
|
|
334
|
+
header.appendChild(statusDot);
|
|
335
|
+
panel.appendChild(header);
|
|
336
|
+
|
|
337
|
+
var termDiv = document.createElement('div');
|
|
338
|
+
termDiv.className = 'grid-panel-terminal';
|
|
339
|
+
panel.appendChild(termDiv);
|
|
340
|
+
|
|
341
|
+
// Append to contentEl so xterm.open has a DOM-attached container
|
|
342
|
+
contentEl.appendChild(panel);
|
|
343
|
+
|
|
344
|
+
// xterm instance
|
|
345
|
+
var panelXterm = new Terminal({
|
|
346
|
+
theme: {
|
|
347
|
+
background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
|
|
348
|
+
selectionBackground: '#264f78',
|
|
349
|
+
},
|
|
350
|
+
fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
|
|
351
|
+
fontSize: 11,
|
|
352
|
+
scrollback: 1000,
|
|
353
|
+
cursorBlink: true,
|
|
354
|
+
});
|
|
355
|
+
var panelFit = new FitAddon.FitAddon();
|
|
356
|
+
panelXterm.loadAddon(panelFit);
|
|
357
|
+
panelXterm.open(termDiv);
|
|
358
|
+
|
|
359
|
+
// Store entry before async connect so index is stable
|
|
360
|
+
var entry = { xterm: panelXterm, fitAddon: panelFit, ws: null, session: s, panel: panel };
|
|
361
|
+
gridTerminals.push(entry);
|
|
362
|
+
|
|
363
|
+
// Connect WebSocket to this session
|
|
364
|
+
(function connectPanel() {
|
|
365
|
+
// Use hub's proxy endpoint to get a ticket for the session
|
|
366
|
+
var tokenParam = new URLSearchParams(window.location.search).get('token');
|
|
367
|
+
var proxyUrl = '/api/proxy/ticket/' + s.port;
|
|
368
|
+
var wsBase = s.isLocal ? 'ws://127.0.0.1:' + s.port : s.url.replace('https://', 'wss://');
|
|
369
|
+
|
|
370
|
+
fetch(proxyUrl, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'Authorization': 'Bearer ' + tokenParam }
|
|
373
|
+
}).then(function(resp) {
|
|
374
|
+
if (!resp.ok) throw new Error('Auth failed');
|
|
375
|
+
return resp.json();
|
|
376
|
+
}).then(function(data) {
|
|
377
|
+
var panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
|
|
378
|
+
entry.ws = panelWs;
|
|
379
|
+
|
|
380
|
+
panelWs.onopen = function() {
|
|
381
|
+
if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
|
|
382
|
+
panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
|
|
383
|
+
};
|
|
384
|
+
panelWs.onclose = function() {
|
|
385
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
|
|
386
|
+
};
|
|
387
|
+
panelWs.onerror = function() {
|
|
388
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; }
|
|
389
|
+
};
|
|
390
|
+
panelWs.onmessage = function(e) {
|
|
391
|
+
try {
|
|
392
|
+
var msg = JSON.parse(e.data);
|
|
393
|
+
if (msg.type === 'pty') {
|
|
394
|
+
panelXterm.write(msg.data);
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
panelXterm.onData(function(data) {
|
|
400
|
+
if (panelWs && panelWs.readyState === WebSocket.OPEN) {
|
|
401
|
+
panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}).catch(function() {
|
|
405
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
|
|
406
|
+
});
|
|
407
|
+
})();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ── Event delegation for panel clicks ──
|
|
411
|
+
contentEl.addEventListener('click', function(e) {
|
|
412
|
+
var panel = e.target.closest('.grid-panel');
|
|
413
|
+
if (!panel) return;
|
|
414
|
+
var idx = parseInt(panel.dataset.index, 10);
|
|
415
|
+
if (isNaN(idx)) return;
|
|
416
|
+
|
|
417
|
+
if (gridMode === 'thumbnails') {
|
|
418
|
+
focusedIndex = idx;
|
|
419
|
+
switchGridMode('fullscreen');
|
|
420
|
+
} else if (gridMode === 'focus' && panel.classList.contains('focus-strip')) {
|
|
421
|
+
focusedIndex = idx;
|
|
422
|
+
applyGridLayout('focus');
|
|
423
|
+
} else if (gridMode === 'tmux') {
|
|
424
|
+
focusedIndex = idx;
|
|
425
|
+
contentEl.querySelectorAll('.grid-panel').forEach(function(p) { p.classList.remove('active'); });
|
|
426
|
+
panel.classList.add('active');
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Apply initial layout
|
|
431
|
+
applyGridLayout(gridMode);
|
|
432
|
+
|
|
433
|
+
// Handle window resize
|
|
434
|
+
window.removeEventListener('resize', fitGridPanels);
|
|
435
|
+
window.addEventListener('resize', fitGridPanels);
|
|
436
|
+
if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function switchGridMode(mode) {
|
|
440
|
+
gridMode = mode;
|
|
441
|
+
if (mode === 'fullscreen') {
|
|
442
|
+
$('#input-area').classList.remove('hidden');
|
|
443
|
+
$('#input-form').classList.add('hidden');
|
|
444
|
+
} else {
|
|
445
|
+
$('#input-area').classList.add('hidden');
|
|
446
|
+
}
|
|
447
|
+
applyGridLayout(mode);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function switchTmuxPreset(preset) {
|
|
451
|
+
tmuxPreset = preset;
|
|
452
|
+
var presetGroup = document.getElementById('tmux-presets');
|
|
453
|
+
if (presetGroup) {
|
|
454
|
+
presetGroup.querySelectorAll('[data-preset]').forEach(function(btn) {
|
|
455
|
+
btn.classList.toggle('active', btn.dataset.preset === preset);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
if (gridMode === 'tmux') applyGridLayout('tmux');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function applyGridLayout(mode) {
|
|
462
|
+
gridMode = mode;
|
|
463
|
+
var contentEl = document.getElementById('grid-content');
|
|
464
|
+
if (!contentEl || gridTerminals.length === 0) return;
|
|
465
|
+
|
|
466
|
+
// Clamp focusedIndex
|
|
467
|
+
if (focusedIndex >= gridTerminals.length) focusedIndex = 0;
|
|
468
|
+
|
|
469
|
+
// Update toolbar button states
|
|
470
|
+
var toolbar = contentEl.parentElement.querySelector('.grid-toolbar');
|
|
471
|
+
if (toolbar) {
|
|
472
|
+
toolbar.querySelectorAll('[data-mode]').forEach(function(btn) {
|
|
473
|
+
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
474
|
+
});
|
|
475
|
+
var presetsEl = document.getElementById('tmux-presets');
|
|
476
|
+
if (presetsEl) presetsEl.classList.toggle('hidden', mode !== 'tmux');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Detach all panels without destroying them
|
|
480
|
+
gridTerminals.forEach(function(gt, i) {
|
|
481
|
+
if (gt.panel.parentNode) gt.panel.parentNode.removeChild(gt.panel);
|
|
482
|
+
gt.panel.className = 'grid-panel';
|
|
483
|
+
gt.panel.dataset.index = i;
|
|
484
|
+
var termDiv = gt.panel.querySelector('.grid-panel-terminal');
|
|
485
|
+
if (termDiv) termDiv.style.cssText = '';
|
|
486
|
+
gt.panel.style.cssText = '';
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Remove leftover elements (focus-strips, back-to-grid button)
|
|
490
|
+
while (contentEl.firstChild) contentEl.removeChild(contentEl.firstChild);
|
|
491
|
+
|
|
492
|
+
// Reset content styles
|
|
493
|
+
contentEl.className = 'mode-' + mode;
|
|
494
|
+
contentEl.style.cssText = '';
|
|
495
|
+
|
|
496
|
+
switch (mode) {
|
|
497
|
+
case 'thumbnails':
|
|
498
|
+
gridTerminals.forEach(function(gt) {
|
|
499
|
+
gt.panel.classList.add('thumbnail');
|
|
500
|
+
var termDiv = gt.panel.querySelector('.grid-panel-terminal');
|
|
501
|
+
termDiv.style.width = '560px';
|
|
502
|
+
termDiv.style.height = '360px';
|
|
503
|
+
termDiv.style.transform = 'scale(0.5)';
|
|
504
|
+
termDiv.style.transformOrigin = 'top left';
|
|
505
|
+
contentEl.appendChild(gt.panel);
|
|
506
|
+
});
|
|
507
|
+
break;
|
|
508
|
+
|
|
509
|
+
case 'tmux':
|
|
510
|
+
gridTerminals.forEach(function(gt, i) {
|
|
511
|
+
if (i === focusedIndex) gt.panel.classList.add('active');
|
|
512
|
+
contentEl.appendChild(gt.panel);
|
|
513
|
+
});
|
|
514
|
+
if (tmuxPreset === 'equal') {
|
|
515
|
+
var cols = gridTerminals.length <= 2 ? gridTerminals.length : gridTerminals.length <= 4 ? 2 : 3;
|
|
516
|
+
contentEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
|
517
|
+
} else if (tmuxPreset === 'main-side') {
|
|
518
|
+
contentEl.style.gridTemplateColumns = '70% 30%';
|
|
519
|
+
var sideCount = Math.max(gridTerminals.length - 1, 1);
|
|
520
|
+
contentEl.style.gridTemplateRows = 'repeat(' + sideCount + ', 1fr)';
|
|
521
|
+
if (gridTerminals.length > 0) gridTerminals[0].panel.style.gridRow = '1 / -1';
|
|
522
|
+
} else if (tmuxPreset === 'stacked') {
|
|
523
|
+
contentEl.style.gridTemplateColumns = '1fr';
|
|
524
|
+
}
|
|
525
|
+
break;
|
|
526
|
+
|
|
527
|
+
case 'focus':
|
|
528
|
+
var mainGt = gridTerminals[focusedIndex];
|
|
529
|
+
mainGt.panel.classList.add('focus-main');
|
|
530
|
+
contentEl.appendChild(mainGt.panel);
|
|
531
|
+
if (gridTerminals.length > 1) {
|
|
532
|
+
var stripsEl = document.createElement('div');
|
|
533
|
+
stripsEl.className = 'focus-strips';
|
|
534
|
+
gridTerminals.forEach(function(gt, i) {
|
|
535
|
+
if (i === focusedIndex) return;
|
|
536
|
+
gt.panel.classList.add('focus-strip');
|
|
537
|
+
stripsEl.appendChild(gt.panel);
|
|
538
|
+
});
|
|
539
|
+
contentEl.appendChild(stripsEl);
|
|
540
|
+
}
|
|
541
|
+
break;
|
|
542
|
+
|
|
543
|
+
case 'fullscreen':
|
|
544
|
+
var fullGt = gridTerminals[focusedIndex];
|
|
545
|
+
fullGt.panel.classList.add('fullscreen');
|
|
546
|
+
contentEl.appendChild(fullGt.panel);
|
|
547
|
+
var backBtn = document.createElement('button');
|
|
548
|
+
backBtn.className = 'back-to-grid';
|
|
549
|
+
backBtn.textContent = '\u2190 Grid';
|
|
550
|
+
backBtn.addEventListener('click', function() { switchGridMode('thumbnails'); });
|
|
551
|
+
contentEl.appendChild(backBtn);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Fit visible terminals after DOM settles
|
|
556
|
+
setTimeout(function() {
|
|
557
|
+
gridTerminals.forEach(function(gt) {
|
|
558
|
+
if (!document.contains(gt.panel)) return;
|
|
559
|
+
if (gt.fitAddon) {
|
|
560
|
+
try {
|
|
561
|
+
gt.fitAddon.fit();
|
|
562
|
+
if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
|
|
563
|
+
gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
|
|
564
|
+
}
|
|
565
|
+
} catch(e) {}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
}, 100);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function fitGridPanels() {
|
|
572
|
+
gridTerminals.forEach(function(gt) {
|
|
573
|
+
if (!document.contains(gt.panel)) return;
|
|
574
|
+
if (gt.fitAddon) {
|
|
575
|
+
try {
|
|
576
|
+
gt.fitAddon.fit();
|
|
577
|
+
if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
|
|
578
|
+
gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
|
|
579
|
+
}
|
|
580
|
+
} catch(e) {}
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function destroyGrid() {
|
|
586
|
+
gridTerminals.forEach(function(gt) {
|
|
587
|
+
if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
|
|
588
|
+
if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
|
|
589
|
+
});
|
|
590
|
+
gridTerminals = [];
|
|
591
|
+
window.removeEventListener('resize', fitGridPanels);
|
|
592
|
+
var gridEl = document.getElementById('grid-view');
|
|
593
|
+
if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
|
|
594
|
+
$('#input-area').classList.add('hidden');
|
|
595
|
+
gridMode = 'thumbnails';
|
|
596
|
+
focusedIndex = 0;
|
|
597
|
+
tmuxPreset = 'equal';
|
|
598
|
+
}
|
|
599
|
+
|
|
191
600
|
window.toggleView = () => {
|
|
601
|
+
if (currentView === 'grid') {
|
|
602
|
+
// Grid → dashboard (list view)
|
|
603
|
+
destroyGrid();
|
|
604
|
+
currentView = 'dashboard';
|
|
605
|
+
dashboard.classList.remove('hidden');
|
|
606
|
+
$('#btn-sessions').textContent = 'Terminal';
|
|
607
|
+
loadSessions();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
192
610
|
if (currentView === 'terminal') {
|
|
193
611
|
currentView = 'dashboard';
|
|
194
612
|
terminal.classList.add('hidden');
|
|
@@ -198,6 +616,7 @@
|
|
|
198
616
|
$('#btn-sessions').textContent = 'Terminal';
|
|
199
617
|
loadSessions();
|
|
200
618
|
} else {
|
|
619
|
+
destroyGrid();
|
|
201
620
|
currentView = 'terminal';
|
|
202
621
|
dashboard.classList.add('hidden');
|
|
203
622
|
$('#input-area').classList.remove('hidden');
|
|
@@ -367,7 +786,7 @@
|
|
|
367
786
|
}
|
|
368
787
|
|
|
369
788
|
// ─── Detect hub mode (no token in URL) ────────────────────
|
|
370
|
-
const isHubMode =
|
|
789
|
+
const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
|
|
371
790
|
|
|
372
791
|
// ─── WebSocket ───────────────────────────────────────────
|
|
373
792
|
let reconnectAttempt = 0;
|
|
@@ -392,7 +811,7 @@
|
|
|
392
811
|
|
|
393
812
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
394
813
|
|
|
395
|
-
// F-02:
|
|
814
|
+
// F-02: Ticket-based auth (required)
|
|
396
815
|
try {
|
|
397
816
|
const resp = await fetch('/api/auth/ticket', {
|
|
398
817
|
method: 'POST',
|
|
@@ -402,12 +821,12 @@
|
|
|
402
821
|
const { ticket } = await resp.json();
|
|
403
822
|
ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
|
|
404
823
|
} else {
|
|
405
|
-
|
|
406
|
-
|
|
824
|
+
setStatus('offline', 'Auth failed');
|
|
825
|
+
return;
|
|
407
826
|
}
|
|
408
827
|
} catch {
|
|
409
|
-
|
|
410
|
-
|
|
828
|
+
setStatus('offline', 'Auth failed');
|
|
829
|
+
return;
|
|
411
830
|
}
|
|
412
831
|
setStatus('connecting', 'Connecting...');
|
|
413
832
|
|
|
@@ -562,6 +981,33 @@
|
|
|
562
981
|
};
|
|
563
982
|
|
|
564
983
|
// ─── Mobile Key Bar ───────────────────────────────────────
|
|
984
|
+
// F-5: Event delegation for key-bar buttons (no inline onclick)
|
|
985
|
+
const keyBar = document.getElementById('key-bar');
|
|
986
|
+
if (keyBar) {
|
|
987
|
+
var keyMap = {
|
|
988
|
+
'\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
|
|
989
|
+
'\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
|
|
990
|
+
};
|
|
991
|
+
keyBar.addEventListener('click', function(e) {
|
|
992
|
+
var btn = e.target;
|
|
993
|
+
if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
|
|
994
|
+
var key = keyMap[btn.dataset.key] || btn.dataset.key;
|
|
995
|
+
if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
|
|
996
|
+
var gt = gridTerminals[focusedIndex];
|
|
997
|
+
if (gt.ws && gt.ws.readyState === WebSocket.OPEN) {
|
|
998
|
+
gt.ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
|
999
|
+
}
|
|
1000
|
+
if (gt.xterm) gt.xterm.focus();
|
|
1001
|
+
} else {
|
|
1002
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1003
|
+
ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
|
1004
|
+
}
|
|
1005
|
+
if (xterm) xterm.focus();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
565
1011
|
window.sendKey = (key) => {
|
|
566
1012
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
567
1013
|
ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
|
@@ -606,7 +1052,7 @@
|
|
|
606
1052
|
requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
|
|
607
1053
|
}
|
|
608
1054
|
function escapeHtml(s) {
|
|
609
|
-
const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, ''');
|
|
1055
|
+
const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, ''').replace(/"/g, '"');
|
|
610
1056
|
}
|
|
611
1057
|
function formatText(text) {
|
|
612
1058
|
return escapeHtml(text)
|
package/remote-ui/index.html
CHANGED
|
@@ -32,16 +32,16 @@
|
|
|
32
32
|
|
|
33
33
|
<footer id="input-area">
|
|
34
34
|
<div id="key-bar">
|
|
35
|
-
<button
|
|
36
|
-
<button
|
|
37
|
-
<button
|
|
38
|
-
<button
|
|
39
|
-
<button
|
|
40
|
-
<button
|
|
41
|
-
<button
|
|
42
|
-
<button
|
|
43
|
-
<button
|
|
44
|
-
<button
|
|
35
|
+
<button data-key="\x1b[A">↑</button>
|
|
36
|
+
<button data-key="\x1b[B">↓</button>
|
|
37
|
+
<button data-key="\x1b[C">→</button>
|
|
38
|
+
<button data-key="\x1b[D">←</button>
|
|
39
|
+
<button data-key="\t">Tab</button>
|
|
40
|
+
<button data-key="\r">Enter</button>
|
|
41
|
+
<button data-key="\x1b">Esc</button>
|
|
42
|
+
<button data-key="\x03">Ctrl+C</button>
|
|
43
|
+
<button data-key=" ">Space</button>
|
|
44
|
+
<button data-key="\x7f">⌫</button>
|
|
45
45
|
</div>
|
|
46
46
|
<form id="input-form">
|
|
47
47
|
<span class="prompt">></span>
|