cli-tunnel 1.2.0-beta.10 → 1.2.0-beta.12
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/dist/index.js +40 -6
- package/package.json +1 -1
- package/remote-ui/app.js +311 -51
- package/remote-ui/styles.css +131 -3
package/dist/index.js
CHANGED
|
@@ -228,7 +228,7 @@ setInterval(() => {
|
|
|
228
228
|
ticketRateLimits.delete(ip);
|
|
229
229
|
}
|
|
230
230
|
}, 60000);
|
|
231
|
-
const server = http.createServer((req, res) => {
|
|
231
|
+
const server = http.createServer(async (req, res) => {
|
|
232
232
|
const clientIp = req.socket.remoteAddress || 'unknown';
|
|
233
233
|
// F-8: Rate limiting for HTTP endpoints
|
|
234
234
|
if (req.url?.startsWith('/api/')) {
|
|
@@ -274,6 +274,40 @@ const server = http.createServer((req, res) => {
|
|
|
274
274
|
return;
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
|
+
// Hub ticket proxy — fetch ticket from local session on behalf of grid client
|
|
278
|
+
if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
|
|
279
|
+
const targetPort = parseInt(req.url.replace('/api/proxy/ticket/', ''), 10);
|
|
280
|
+
if (!Number.isFinite(targetPort) || targetPort < 1 || targetPort > 65535) {
|
|
281
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
282
|
+
res.end(JSON.stringify({ error: 'Invalid port' }));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Find token for this port from session files
|
|
286
|
+
const localSessions = readLocalSessions();
|
|
287
|
+
const session = localSessions.find(s => s.port === targetPort);
|
|
288
|
+
if (!session) {
|
|
289
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
290
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const ticketResp = await fetch(`http://127.0.0.1:${targetPort}/api/auth/ticket`, {
|
|
295
|
+
method: 'POST', headers: { 'Authorization': `Bearer ${session.token}` },
|
|
296
|
+
signal: AbortSignal.timeout(3000),
|
|
297
|
+
});
|
|
298
|
+
if (!ticketResp.ok)
|
|
299
|
+
throw new Error('Ticket request failed');
|
|
300
|
+
const ticketData = await ticketResp.json();
|
|
301
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
302
|
+
res.end(JSON.stringify({ ticket: ticketData.ticket, port: targetPort }));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
306
|
+
res.end(JSON.stringify({ error: 'Session unreachable' }));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
277
311
|
// Sessions API
|
|
278
312
|
if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
|
|
279
313
|
try {
|
|
@@ -304,7 +338,7 @@ const server = http.createServer((req, res) => {
|
|
|
304
338
|
const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
|
|
305
339
|
const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
|
|
306
340
|
if (token)
|
|
307
|
-
session.
|
|
341
|
+
session.hasToken = true;
|
|
308
342
|
return session;
|
|
309
343
|
});
|
|
310
344
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
@@ -383,7 +417,7 @@ const server = http.createServer((req, res) => {
|
|
|
383
417
|
'Content-Type': mimes[ext] || 'application/octet-stream',
|
|
384
418
|
'X-Frame-Options': 'DENY',
|
|
385
419
|
'X-Content-Type-Options': 'nosniff',
|
|
386
|
-
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
|
|
420
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* wss://*.devtunnels.ms https://*.devtunnels.ms;",
|
|
387
421
|
'Referrer-Policy': 'no-referrer',
|
|
388
422
|
'Cache-Control': 'no-store',
|
|
389
423
|
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
@@ -400,8 +434,6 @@ const wss = new WebSocketServer({
|
|
|
400
434
|
server,
|
|
401
435
|
maxPayload: 1048576,
|
|
402
436
|
verifyClient: (info) => {
|
|
403
|
-
if (hubMode)
|
|
404
|
-
return true; // Hub mode doesn't need WS auth
|
|
405
437
|
// F-18: Session expiry
|
|
406
438
|
if (Date.now() - sessionCreatedAt > SESSION_TTL)
|
|
407
439
|
return false;
|
|
@@ -732,7 +764,9 @@ async function main() {
|
|
|
732
764
|
const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
|
|
733
765
|
'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
|
|
734
766
|
'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
|
|
735
|
-
'SSH_AUTH_SOCK', 'GPG_TTY'
|
|
767
|
+
'SSH_AUTH_SOCK', 'GPG_TTY',
|
|
768
|
+
'PYTHONPATH', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
|
|
769
|
+
'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT']);
|
|
736
770
|
const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
|
|
737
771
|
const safeEnv = {};
|
|
738
772
|
for (const [k, v] of Object.entries(process.env)) {
|
package/package.json
CHANGED
package/remote-ui/app.js
CHANGED
|
@@ -47,7 +47,10 @@
|
|
|
47
47
|
const termContainer = $('#terminal-container');
|
|
48
48
|
let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
|
|
49
49
|
let cachedSessions = [];
|
|
50
|
-
let gridTerminals = []; // { xterm, fitAddon, ws, session }
|
|
50
|
+
let gridTerminals = []; // { xterm, fitAddon, ws, session, panel }
|
|
51
|
+
var gridMode = 'thumbnails';
|
|
52
|
+
var focusedIndex = 0;
|
|
53
|
+
var tmuxPreset = 'equal';
|
|
51
54
|
|
|
52
55
|
// ─── xterm.js Terminal ───────────────────────────────────
|
|
53
56
|
let xterm = null;
|
|
@@ -117,7 +120,9 @@
|
|
|
117
120
|
|
|
118
121
|
async function loadSessions() {
|
|
119
122
|
try {
|
|
120
|
-
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 });
|
|
121
126
|
const data = await resp.json();
|
|
122
127
|
renderDashboard(data.sessions || []);
|
|
123
128
|
} catch (err) {
|
|
@@ -146,17 +151,17 @@
|
|
|
146
151
|
'</div>';
|
|
147
152
|
} else {
|
|
148
153
|
html += filtered.map(s => {
|
|
149
|
-
const
|
|
154
|
+
const hasAccess = s.hasToken;
|
|
150
155
|
return `
|
|
151
|
-
<div class="session-card" ${s.online &&
|
|
156
|
+
<div class="session-card" ${s.online && hasAccess ? 'data-session-port="' + s.port + '" data-session-base-url="' + escapeHtml(s.url) + '"' : ''}>
|
|
152
157
|
<span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
|
|
153
158
|
<div class="info">
|
|
154
159
|
<div class="session-name">${escapeHtml(s.name)}</div>
|
|
155
160
|
<div class="repo">📦 ${escapeHtml(s.repo)}</div>
|
|
156
161
|
<div class="branch">🌿 ${escapeHtml(s.branch)}</div>
|
|
157
|
-
<div class="machine">💻 ${escapeHtml(s.machine)}${!
|
|
162
|
+
<div class="machine">💻 ${escapeHtml(s.machine)}${!hasAccess && s.online ? ' 🔒' : ''}</div>
|
|
158
163
|
</div>
|
|
159
|
-
${s.online &&
|
|
164
|
+
${s.online && hasAccess ? '<span class="arrow">→</span>' :
|
|
160
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>'
|
|
161
166
|
: '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
|
|
162
167
|
</div>`;
|
|
@@ -165,8 +170,25 @@
|
|
|
165
170
|
dashboard.innerHTML = html;
|
|
166
171
|
cachedSessions = sessions;
|
|
167
172
|
// Event delegation
|
|
168
|
-
dashboard.querySelectorAll('.session-card[data-session-
|
|
169
|
-
card.addEventListener('click', function() {
|
|
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
|
+
});
|
|
170
192
|
});
|
|
171
193
|
dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
|
|
172
194
|
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
|
|
@@ -187,29 +209,36 @@
|
|
|
187
209
|
};
|
|
188
210
|
|
|
189
211
|
window.cleanOffline = async () => {
|
|
190
|
-
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 });
|
|
191
215
|
const data = await resp.json();
|
|
192
216
|
const offline = (data.sessions || []).filter(s => !s.online);
|
|
193
217
|
for (const s of offline) {
|
|
194
|
-
await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
|
|
218
|
+
await fetch('/api/sessions/' + s.id, { method: 'DELETE', headers });
|
|
195
219
|
}
|
|
196
220
|
loadSessions();
|
|
197
221
|
};
|
|
198
222
|
|
|
199
223
|
window.deleteSession = async (id) => {
|
|
200
|
-
|
|
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 });
|
|
201
227
|
loadSessions();
|
|
202
228
|
};
|
|
203
229
|
|
|
204
|
-
// ─── Grid View (
|
|
230
|
+
// ─── Grid View (multi-terminal with layout modes) ───────────
|
|
205
231
|
function showGridView(sessions) {
|
|
206
|
-
|
|
232
|
+
var connectable = sessions.filter(function(s) { return s.online && s.token; });
|
|
207
233
|
if (connectable.length === 0) return;
|
|
208
234
|
|
|
209
235
|
// Clean up previous grid
|
|
210
236
|
destroyGrid();
|
|
211
237
|
|
|
212
238
|
currentView = 'grid';
|
|
239
|
+
gridMode = 'thumbnails';
|
|
240
|
+
focusedIndex = 0;
|
|
241
|
+
tmuxPreset = 'equal';
|
|
213
242
|
dashboard.classList.add('hidden');
|
|
214
243
|
terminal.classList.add('hidden');
|
|
215
244
|
termContainer.classList.add('hidden');
|
|
@@ -224,30 +253,95 @@
|
|
|
224
253
|
gridEl.classList.remove('hidden');
|
|
225
254
|
gridEl.innerHTML = '';
|
|
226
255
|
|
|
227
|
-
//
|
|
228
|
-
var
|
|
229
|
-
|
|
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);
|
|
230
297
|
|
|
231
|
-
|
|
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) {
|
|
232
317
|
var panel = document.createElement('div');
|
|
233
318
|
panel.className = 'grid-panel';
|
|
319
|
+
panel.dataset.index = index;
|
|
234
320
|
|
|
235
|
-
// Header
|
|
236
321
|
var header = document.createElement('div');
|
|
237
322
|
header.className = 'grid-panel-header';
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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);
|
|
241
335
|
panel.appendChild(header);
|
|
242
336
|
|
|
243
|
-
// Terminal container
|
|
244
337
|
var termDiv = document.createElement('div');
|
|
245
338
|
termDiv.className = 'grid-panel-terminal';
|
|
246
339
|
panel.appendChild(termDiv);
|
|
247
340
|
|
|
248
|
-
|
|
341
|
+
// Append to contentEl so xterm.open has a DOM-attached container
|
|
342
|
+
contentEl.appendChild(panel);
|
|
249
343
|
|
|
250
|
-
//
|
|
344
|
+
// xterm instance
|
|
251
345
|
var panelXterm = new Terminal({
|
|
252
346
|
theme: {
|
|
253
347
|
background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
|
|
@@ -262,23 +356,26 @@
|
|
|
262
356
|
panelXterm.loadAddon(panelFit);
|
|
263
357
|
panelXterm.open(termDiv);
|
|
264
358
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// Connect WebSocket to this session via ticket auth
|
|
269
|
-
var statusDot = header.querySelector('.grid-panel-status');
|
|
270
|
-
var panelWs = null;
|
|
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);
|
|
271
362
|
|
|
363
|
+
// Connect WebSocket to this session
|
|
272
364
|
(function connectPanel() {
|
|
273
|
-
|
|
274
|
-
|
|
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, {
|
|
275
371
|
method: 'POST',
|
|
276
|
-
headers: { 'Authorization': 'Bearer ' +
|
|
372
|
+
headers: { 'Authorization': 'Bearer ' + tokenParam }
|
|
277
373
|
}).then(function(resp) {
|
|
278
374
|
if (!resp.ok) throw new Error('Auth failed');
|
|
279
375
|
return resp.json();
|
|
280
376
|
}).then(function(data) {
|
|
281
|
-
panelWs = new WebSocket(
|
|
377
|
+
var panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
|
|
378
|
+
entry.ws = panelWs;
|
|
282
379
|
|
|
283
380
|
panelWs.onopen = function() {
|
|
284
381
|
if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
|
|
@@ -304,32 +401,183 @@
|
|
|
304
401
|
panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
|
|
305
402
|
}
|
|
306
403
|
});
|
|
307
|
-
|
|
308
|
-
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
|
|
309
404
|
}).catch(function() {
|
|
310
405
|
if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
|
|
311
|
-
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: null, session: s });
|
|
312
406
|
});
|
|
313
407
|
})();
|
|
408
|
+
});
|
|
314
409
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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.addEventListener('resize', fitGridPanels);
|
|
435
|
+
if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function switchGridMode(mode) {
|
|
439
|
+
gridMode = mode;
|
|
440
|
+
if (mode === 'fullscreen') {
|
|
441
|
+
$('#input-area').classList.remove('hidden');
|
|
442
|
+
$('#input-form').classList.add('hidden');
|
|
443
|
+
} else {
|
|
444
|
+
$('#input-area').classList.add('hidden');
|
|
445
|
+
}
|
|
446
|
+
applyGridLayout(mode);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function switchTmuxPreset(preset) {
|
|
450
|
+
tmuxPreset = preset;
|
|
451
|
+
var presetGroup = document.getElementById('tmux-presets');
|
|
452
|
+
if (presetGroup) {
|
|
453
|
+
presetGroup.querySelectorAll('[data-preset]').forEach(function(btn) {
|
|
454
|
+
btn.classList.toggle('active', btn.dataset.preset === preset);
|
|
318
455
|
});
|
|
456
|
+
}
|
|
457
|
+
if (gridMode === 'tmux') applyGridLayout('tmux');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function applyGridLayout(mode) {
|
|
461
|
+
gridMode = mode;
|
|
462
|
+
var contentEl = document.getElementById('grid-content');
|
|
463
|
+
if (!contentEl || gridTerminals.length === 0) return;
|
|
319
464
|
|
|
320
|
-
|
|
465
|
+
// Clamp focusedIndex
|
|
466
|
+
if (focusedIndex >= gridTerminals.length) focusedIndex = 0;
|
|
467
|
+
|
|
468
|
+
// Update toolbar button states
|
|
469
|
+
var toolbar = contentEl.parentElement.querySelector('.grid-toolbar');
|
|
470
|
+
if (toolbar) {
|
|
471
|
+
toolbar.querySelectorAll('[data-mode]').forEach(function(btn) {
|
|
472
|
+
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
473
|
+
});
|
|
474
|
+
var presetsEl = document.getElementById('tmux-presets');
|
|
475
|
+
if (presetsEl) presetsEl.classList.toggle('hidden', mode !== 'tmux');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Detach all panels without destroying them
|
|
479
|
+
gridTerminals.forEach(function(gt, i) {
|
|
480
|
+
if (gt.panel.parentNode) gt.panel.parentNode.removeChild(gt.panel);
|
|
481
|
+
gt.panel.className = 'grid-panel';
|
|
482
|
+
gt.panel.dataset.index = i;
|
|
483
|
+
var termDiv = gt.panel.querySelector('.grid-panel-terminal');
|
|
484
|
+
if (termDiv) termDiv.style.cssText = '';
|
|
485
|
+
gt.panel.style.cssText = '';
|
|
321
486
|
});
|
|
322
487
|
|
|
323
|
-
//
|
|
324
|
-
|
|
488
|
+
// Remove leftover elements (focus-strips, back-to-grid button)
|
|
489
|
+
while (contentEl.firstChild) contentEl.removeChild(contentEl.firstChild);
|
|
490
|
+
|
|
491
|
+
// Reset content styles
|
|
492
|
+
contentEl.className = 'mode-' + mode;
|
|
493
|
+
contentEl.style.cssText = '';
|
|
494
|
+
|
|
495
|
+
switch (mode) {
|
|
496
|
+
case 'thumbnails':
|
|
497
|
+
gridTerminals.forEach(function(gt) {
|
|
498
|
+
gt.panel.classList.add('thumbnail');
|
|
499
|
+
var termDiv = gt.panel.querySelector('.grid-panel-terminal');
|
|
500
|
+
termDiv.style.width = '560px';
|
|
501
|
+
termDiv.style.height = '360px';
|
|
502
|
+
termDiv.style.transform = 'scale(0.5)';
|
|
503
|
+
termDiv.style.transformOrigin = 'top left';
|
|
504
|
+
contentEl.appendChild(gt.panel);
|
|
505
|
+
});
|
|
506
|
+
break;
|
|
325
507
|
|
|
326
|
-
|
|
327
|
-
|
|
508
|
+
case 'tmux':
|
|
509
|
+
gridTerminals.forEach(function(gt, i) {
|
|
510
|
+
if (i === focusedIndex) gt.panel.classList.add('active');
|
|
511
|
+
contentEl.appendChild(gt.panel);
|
|
512
|
+
});
|
|
513
|
+
if (tmuxPreset === 'equal') {
|
|
514
|
+
var cols = gridTerminals.length <= 2 ? gridTerminals.length : gridTerminals.length <= 4 ? 2 : 3;
|
|
515
|
+
contentEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
|
516
|
+
} else if (tmuxPreset === 'main-side') {
|
|
517
|
+
contentEl.style.gridTemplateColumns = '70% 30%';
|
|
518
|
+
var sideCount = Math.max(gridTerminals.length - 1, 1);
|
|
519
|
+
contentEl.style.gridTemplateRows = 'repeat(' + sideCount + ', 1fr)';
|
|
520
|
+
if (gridTerminals.length > 0) gridTerminals[0].panel.style.gridRow = '1 / -1';
|
|
521
|
+
} else if (tmuxPreset === 'stacked') {
|
|
522
|
+
contentEl.style.gridTemplateColumns = '1fr';
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
|
|
526
|
+
case 'focus':
|
|
527
|
+
var mainGt = gridTerminals[focusedIndex];
|
|
528
|
+
mainGt.panel.classList.add('focus-main');
|
|
529
|
+
contentEl.appendChild(mainGt.panel);
|
|
530
|
+
if (gridTerminals.length > 1) {
|
|
531
|
+
var stripsEl = document.createElement('div');
|
|
532
|
+
stripsEl.className = 'focus-strips';
|
|
533
|
+
gridTerminals.forEach(function(gt, i) {
|
|
534
|
+
if (i === focusedIndex) return;
|
|
535
|
+
gt.panel.classList.add('focus-strip');
|
|
536
|
+
stripsEl.appendChild(gt.panel);
|
|
537
|
+
});
|
|
538
|
+
contentEl.appendChild(stripsEl);
|
|
539
|
+
}
|
|
540
|
+
break;
|
|
541
|
+
|
|
542
|
+
case 'fullscreen':
|
|
543
|
+
var fullGt = gridTerminals[focusedIndex];
|
|
544
|
+
fullGt.panel.classList.add('fullscreen');
|
|
545
|
+
contentEl.appendChild(fullGt.panel);
|
|
546
|
+
var backBtn = document.createElement('button');
|
|
547
|
+
backBtn.className = 'back-to-grid';
|
|
548
|
+
backBtn.textContent = '\u2190 Grid';
|
|
549
|
+
backBtn.addEventListener('click', function() { switchGridMode('thumbnails'); });
|
|
550
|
+
contentEl.appendChild(backBtn);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Fit visible terminals after DOM settles
|
|
555
|
+
setTimeout(function() {
|
|
556
|
+
gridTerminals.forEach(function(gt) {
|
|
557
|
+
if (!document.contains(gt.panel)) return;
|
|
558
|
+
if (gt.fitAddon) {
|
|
559
|
+
try {
|
|
560
|
+
gt.fitAddon.fit();
|
|
561
|
+
if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
|
|
562
|
+
gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
|
|
563
|
+
}
|
|
564
|
+
} catch(e) {}
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}, 100);
|
|
328
568
|
}
|
|
329
569
|
|
|
330
570
|
function fitGridPanels() {
|
|
331
571
|
gridTerminals.forEach(function(gt) {
|
|
332
|
-
if (
|
|
572
|
+
if (!document.contains(gt.panel)) return;
|
|
573
|
+
if (gt.fitAddon) {
|
|
574
|
+
try {
|
|
575
|
+
gt.fitAddon.fit();
|
|
576
|
+
if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
|
|
577
|
+
gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
|
|
578
|
+
}
|
|
579
|
+
} catch(e) {}
|
|
580
|
+
}
|
|
333
581
|
});
|
|
334
582
|
}
|
|
335
583
|
|
|
@@ -342,6 +590,10 @@
|
|
|
342
590
|
window.removeEventListener('resize', fitGridPanels);
|
|
343
591
|
var gridEl = document.getElementById('grid-view');
|
|
344
592
|
if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
|
|
593
|
+
$('#input-area').classList.add('hidden');
|
|
594
|
+
gridMode = 'thumbnails';
|
|
595
|
+
focusedIndex = 0;
|
|
596
|
+
tmuxPreset = 'equal';
|
|
345
597
|
}
|
|
346
598
|
|
|
347
599
|
window.toggleView = () => {
|
|
@@ -731,7 +983,7 @@
|
|
|
731
983
|
// F-5: Event delegation for key-bar buttons (no inline onclick)
|
|
732
984
|
const keyBar = document.getElementById('key-bar');
|
|
733
985
|
if (keyBar) {
|
|
734
|
-
|
|
986
|
+
var keyMap = {
|
|
735
987
|
'\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
|
|
736
988
|
'\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
|
|
737
989
|
};
|
|
@@ -739,10 +991,18 @@
|
|
|
739
991
|
var btn = e.target;
|
|
740
992
|
if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
|
|
741
993
|
var key = keyMap[btn.dataset.key] || btn.dataset.key;
|
|
742
|
-
if (
|
|
743
|
-
|
|
994
|
+
if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
|
|
995
|
+
var gt = gridTerminals[focusedIndex];
|
|
996
|
+
if (gt.ws && gt.ws.readyState === WebSocket.OPEN) {
|
|
997
|
+
gt.ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
|
998
|
+
}
|
|
999
|
+
if (gt.xterm) gt.xterm.focus();
|
|
1000
|
+
} else {
|
|
1001
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1002
|
+
ws.send(JSON.stringify({ type: 'pty_input', data: key }));
|
|
1003
|
+
}
|
|
1004
|
+
if (xterm) xterm.focus();
|
|
744
1005
|
}
|
|
745
|
-
if (xterm) xterm.focus();
|
|
746
1006
|
}
|
|
747
1007
|
});
|
|
748
1008
|
}
|
|
@@ -791,7 +1051,7 @@
|
|
|
791
1051
|
requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
|
|
792
1052
|
}
|
|
793
1053
|
function escapeHtml(s) {
|
|
794
|
-
const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, ''');
|
|
1054
|
+
const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, ''').replace(/"/g, '"');
|
|
795
1055
|
}
|
|
796
1056
|
function formatText(text) {
|
|
797
1057
|
return escapeHtml(text)
|
package/remote-ui/styles.css
CHANGED
|
@@ -247,15 +247,143 @@ header {
|
|
|
247
247
|
.session-card .machine { color: var(--text-dim); font-size: 11px; }
|
|
248
248
|
.session-card .arrow { color: var(--text-dim); }
|
|
249
249
|
|
|
250
|
-
/* Grid View (
|
|
250
|
+
/* Grid View (multi-terminal with layout modes) */
|
|
251
251
|
#grid-view {
|
|
252
252
|
flex: 1;
|
|
253
|
+
display: flex;
|
|
254
|
+
flex-direction: column;
|
|
255
|
+
overflow: hidden;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Grid toolbar */
|
|
259
|
+
.grid-toolbar {
|
|
260
|
+
display: flex;
|
|
261
|
+
gap: 4px;
|
|
262
|
+
padding: 4px 8px;
|
|
263
|
+
background: var(--bg-tool);
|
|
264
|
+
border-bottom: 1px solid var(--border);
|
|
265
|
+
flex-shrink: 0;
|
|
266
|
+
}
|
|
267
|
+
.grid-toolbar button {
|
|
268
|
+
background: var(--bg);
|
|
269
|
+
border: 1px solid var(--border);
|
|
270
|
+
color: var(--text-dim);
|
|
271
|
+
font-family: var(--font);
|
|
272
|
+
font-size: 11px;
|
|
273
|
+
padding: 3px 8px;
|
|
274
|
+
border-radius: 4px;
|
|
275
|
+
cursor: pointer;
|
|
276
|
+
}
|
|
277
|
+
.grid-toolbar button.active {
|
|
278
|
+
border-color: var(--blue);
|
|
279
|
+
color: var(--blue);
|
|
280
|
+
}
|
|
281
|
+
.grid-toolbar .spacer { flex: 1; }
|
|
282
|
+
.grid-toolbar-presets { display: flex; gap: 4px; margin-left: 8px; }
|
|
283
|
+
.grid-toolbar-presets.hidden { display: none; }
|
|
284
|
+
|
|
285
|
+
/* Grid content area */
|
|
286
|
+
#grid-content {
|
|
287
|
+
flex: 1;
|
|
288
|
+
overflow: hidden;
|
|
289
|
+
min-height: 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* Thumbnail mode */
|
|
293
|
+
#grid-content.mode-thumbnails {
|
|
294
|
+
display: flex;
|
|
295
|
+
flex-wrap: wrap;
|
|
296
|
+
gap: 8px;
|
|
297
|
+
padding: 8px;
|
|
298
|
+
align-content: flex-start;
|
|
299
|
+
overflow-y: auto;
|
|
300
|
+
}
|
|
301
|
+
.grid-panel.thumbnail {
|
|
302
|
+
width: 280px;
|
|
303
|
+
height: 200px;
|
|
304
|
+
border: 1px solid var(--border);
|
|
305
|
+
border-radius: 6px;
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
overflow: hidden;
|
|
308
|
+
flex-shrink: 0;
|
|
309
|
+
}
|
|
310
|
+
.grid-panel.thumbnail:hover {
|
|
311
|
+
border-color: var(--blue);
|
|
312
|
+
}
|
|
313
|
+
.grid-panel.thumbnail .grid-panel-terminal {
|
|
314
|
+
overflow: hidden;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* Tmux mode */
|
|
318
|
+
#grid-content.mode-tmux {
|
|
253
319
|
display: grid;
|
|
254
320
|
gap: 2px;
|
|
255
|
-
padding: 2px;
|
|
256
|
-
overflow: hidden;
|
|
257
321
|
background: var(--border);
|
|
258
322
|
}
|
|
323
|
+
.grid-panel.active {
|
|
324
|
+
outline: 2px solid var(--blue);
|
|
325
|
+
outline-offset: -2px;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* Focus mode */
|
|
329
|
+
#grid-content.mode-focus {
|
|
330
|
+
display: flex;
|
|
331
|
+
flex-direction: column;
|
|
332
|
+
}
|
|
333
|
+
.grid-panel.focus-main {
|
|
334
|
+
flex: 1;
|
|
335
|
+
min-height: 0;
|
|
336
|
+
}
|
|
337
|
+
.grid-panel.focus-main .grid-panel-header {
|
|
338
|
+
background: var(--blue);
|
|
339
|
+
}
|
|
340
|
+
.grid-panel.focus-main .grid-panel-name {
|
|
341
|
+
color: #fff;
|
|
342
|
+
}
|
|
343
|
+
.focus-strips {
|
|
344
|
+
display: flex;
|
|
345
|
+
flex-shrink: 0;
|
|
346
|
+
overflow-x: auto;
|
|
347
|
+
}
|
|
348
|
+
.grid-panel.focus-strip {
|
|
349
|
+
height: 80px;
|
|
350
|
+
cursor: pointer;
|
|
351
|
+
border-top: 1px solid var(--border);
|
|
352
|
+
flex: 1;
|
|
353
|
+
min-width: 120px;
|
|
354
|
+
}
|
|
355
|
+
.grid-panel.focus-strip:hover {
|
|
356
|
+
background: var(--bg-tool);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* Fullscreen mode */
|
|
360
|
+
#grid-content.mode-fullscreen {
|
|
361
|
+
display: flex;
|
|
362
|
+
flex-direction: column;
|
|
363
|
+
position: relative;
|
|
364
|
+
}
|
|
365
|
+
.grid-panel.fullscreen {
|
|
366
|
+
flex: 1;
|
|
367
|
+
min-height: 0;
|
|
368
|
+
}
|
|
369
|
+
.back-to-grid {
|
|
370
|
+
position: absolute;
|
|
371
|
+
top: 4px;
|
|
372
|
+
right: 8px;
|
|
373
|
+
background: var(--bg-tool);
|
|
374
|
+
border: 1px solid var(--border);
|
|
375
|
+
color: var(--text-dim);
|
|
376
|
+
font-family: var(--font);
|
|
377
|
+
font-size: 11px;
|
|
378
|
+
padding: 3px 8px;
|
|
379
|
+
border-radius: 4px;
|
|
380
|
+
cursor: pointer;
|
|
381
|
+
z-index: 10;
|
|
382
|
+
}
|
|
383
|
+
.back-to-grid:hover {
|
|
384
|
+
border-color: var(--blue);
|
|
385
|
+
color: var(--blue);
|
|
386
|
+
}
|
|
259
387
|
.grid-panel {
|
|
260
388
|
display: flex;
|
|
261
389
|
flex-direction: column;
|