cli-tunnel 1.2.0-beta.7 → 1.2.0-beta.9
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 +59 -6
- package/package.json +1 -1
- package/remote-ui/app.js +165 -13
- package/remote-ui/styles.css +41 -0
package/dist/index.js
CHANGED
|
@@ -113,6 +113,44 @@ function getGitInfo() {
|
|
|
113
113
|
}
|
|
114
114
|
// ─── Security: Session token for WebSocket auth ────────────
|
|
115
115
|
const sessionToken = crypto.randomUUID();
|
|
116
|
+
// ─── Session file registry (IPC via filesystem) ────────────
|
|
117
|
+
const sessionsDir = path.join(os.homedir(), '.cli-tunnel', 'sessions');
|
|
118
|
+
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
let sessionFilePath = null;
|
|
120
|
+
function writeSessionFile(tunnelId, tunnelUrl, port) {
|
|
121
|
+
sessionFilePath = path.join(sessionsDir, `${tunnelId}.json`);
|
|
122
|
+
const data = JSON.stringify({
|
|
123
|
+
token: sessionToken, name: sessionName || command,
|
|
124
|
+
tunnelId, tunnelUrl, port, hubMode,
|
|
125
|
+
machine: os.hostname(), pid: process.pid,
|
|
126
|
+
createdAt: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
fs.writeFileSync(sessionFilePath, data, { mode: 0o600 });
|
|
129
|
+
}
|
|
130
|
+
function removeSessionFile() {
|
|
131
|
+
if (sessionFilePath) {
|
|
132
|
+
try {
|
|
133
|
+
fs.unlinkSync(sessionFilePath);
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function readLocalSessions() {
|
|
139
|
+
try {
|
|
140
|
+
return fs.readdirSync(sessionsDir)
|
|
141
|
+
.filter(f => f.endsWith('.json'))
|
|
142
|
+
.map(f => { try {
|
|
143
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf-8'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
} })
|
|
148
|
+
.filter((s) => s !== null && !s.hubMode);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
116
154
|
// ─── F-18: Session TTL (24 hours) ──────────────────────────
|
|
117
155
|
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
118
156
|
const sessionCreatedAt = Date.now();
|
|
@@ -189,26 +227,37 @@ const server = http.createServer((req, res) => {
|
|
|
189
227
|
}
|
|
190
228
|
}
|
|
191
229
|
// Sessions API
|
|
192
|
-
if (req.url === '/api/sessions' && req.method === 'GET') {
|
|
230
|
+
if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
|
|
193
231
|
try {
|
|
194
232
|
const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
195
233
|
const data = JSON.parse(output);
|
|
234
|
+
const localMachine = os.hostname();
|
|
235
|
+
const localSessions = hubMode ? readLocalSessions() : [];
|
|
236
|
+
const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
|
|
196
237
|
const sessions = (data.tunnels || []).map((t) => {
|
|
197
238
|
const labels = t.labels || [];
|
|
198
239
|
const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
|
|
199
240
|
const cluster = t.tunnelId?.split('.').pop() || 'euw';
|
|
200
241
|
const portLabel = labels.find((l) => l.startsWith('port-'));
|
|
201
242
|
const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
|
|
202
|
-
|
|
243
|
+
const machine = labels[4] || 'unknown';
|
|
244
|
+
const session = {
|
|
203
245
|
id, tunnelId: t.tunnelId,
|
|
204
246
|
name: labels[1] || 'unnamed',
|
|
205
247
|
repo: labels[2] || 'unknown',
|
|
206
248
|
branch: (labels[3] || 'unknown').replace(/_/g, '/'),
|
|
207
|
-
machine
|
|
249
|
+
machine,
|
|
208
250
|
online: (t.hostConnections || 0) > 0,
|
|
209
251
|
port: p,
|
|
210
252
|
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
253
|
+
isLocal: machine === localMachine,
|
|
211
254
|
};
|
|
255
|
+
// Attach token from local session files (hub mode only)
|
|
256
|
+
const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
|
|
257
|
+
const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
|
|
258
|
+
if (token)
|
|
259
|
+
session.token = token;
|
|
260
|
+
return session;
|
|
212
261
|
});
|
|
213
262
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
214
263
|
res.end(JSON.stringify({ sessions }));
|
|
@@ -243,7 +292,9 @@ const server = http.createServer((req, res) => {
|
|
|
243
292
|
// #18: Guard against malformed URI encoding
|
|
244
293
|
let decodedUrl;
|
|
245
294
|
try {
|
|
246
|
-
|
|
295
|
+
// Strip query string before resolving file path
|
|
296
|
+
const urlPath = (req.url || '/').split('?')[0];
|
|
297
|
+
decodedUrl = decodeURIComponent(urlPath);
|
|
247
298
|
}
|
|
248
299
|
catch {
|
|
249
300
|
res.writeHead(400);
|
|
@@ -531,17 +582,19 @@ async function main() {
|
|
|
531
582
|
});
|
|
532
583
|
const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
|
|
533
584
|
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
|
|
585
|
+
// Write session file for hub discovery
|
|
586
|
+
writeSessionFile(tunnelId, url, actualPort);
|
|
534
587
|
try {
|
|
535
588
|
// @ts-ignore
|
|
536
589
|
const qr = (await import('qrcode-terminal'));
|
|
537
590
|
qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
|
|
538
591
|
}
|
|
539
592
|
catch { }
|
|
540
|
-
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
593
|
+
process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
|
|
541
594
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
542
595
|
}
|
|
543
596
|
catch { } });
|
|
544
|
-
process.on('exit', () => { hostProc.kill(); try {
|
|
597
|
+
process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
|
|
545
598
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
546
599
|
}
|
|
547
600
|
catch { } });
|
package/package.json
CHANGED
package/remote-ui/app.js
CHANGED
|
@@ -45,7 +45,9 @@
|
|
|
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 }
|
|
49
51
|
|
|
50
52
|
// ─── xterm.js Terminal ───────────────────────────────────
|
|
51
53
|
let xterm = null;
|
|
@@ -127,41 +129,52 @@
|
|
|
127
129
|
const filtered = showOffline ? sessions : sessions.filter(s => s.online);
|
|
128
130
|
const offlineCount = sessions.filter(s => !s.online).length;
|
|
129
131
|
const onlineCount = sessions.filter(s => s.online).length;
|
|
132
|
+
const connectable = filtered.filter(s => s.online && s.token);
|
|
130
133
|
|
|
131
134
|
let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
|
|
132
135
|
<span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
|
|
133
136
|
<span style="flex:1"></span>
|
|
134
|
-
<button
|
|
135
|
-
|
|
136
|
-
<button
|
|
137
|
+
${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>' : ''}
|
|
138
|
+
<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>
|
|
139
|
+
${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>' : ''}
|
|
140
|
+
<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
141
|
</div>`;
|
|
138
142
|
|
|
139
143
|
if (filtered.length === 0) {
|
|
140
144
|
html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
|
|
141
|
-
(sessions.length === 0 ? 'No
|
|
145
|
+
(sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
|
|
142
146
|
'</div>';
|
|
143
147
|
} else {
|
|
144
|
-
html += filtered.map(s =>
|
|
145
|
-
|
|
148
|
+
html += filtered.map(s => {
|
|
149
|
+
const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
|
|
150
|
+
return `
|
|
151
|
+
<div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
|
|
146
152
|
<span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
|
|
147
153
|
<div class="info">
|
|
154
|
+
<div class="session-name">${escapeHtml(s.name)}</div>
|
|
148
155
|
<div class="repo">📦 ${escapeHtml(s.repo)}</div>
|
|
149
156
|
<div class="branch">🌿 ${escapeHtml(s.branch)}</div>
|
|
150
|
-
<div class="machine">💻 ${escapeHtml(s.machine)}</div>
|
|
157
|
+
<div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
|
|
151
158
|
</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
|
-
|
|
159
|
+
${s.online && sessionUrl ? '<span class="arrow">→</span>' :
|
|
160
|
+
!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
|
+
: '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
|
|
162
|
+
</div>`;
|
|
163
|
+
}).join('');
|
|
156
164
|
}
|
|
157
165
|
dashboard.innerHTML = html;
|
|
158
|
-
|
|
166
|
+
cachedSessions = sessions;
|
|
167
|
+
// Event delegation
|
|
159
168
|
dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
|
|
160
169
|
card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
|
|
161
170
|
});
|
|
162
171
|
dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
|
|
163
172
|
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
|
|
164
173
|
});
|
|
174
|
+
dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
|
|
175
|
+
dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
|
|
176
|
+
dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
|
|
177
|
+
dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
|
|
165
178
|
}
|
|
166
179
|
|
|
167
180
|
window.openSession = (url) => {
|
|
@@ -188,7 +201,145 @@
|
|
|
188
201
|
loadSessions();
|
|
189
202
|
};
|
|
190
203
|
|
|
204
|
+
// ─── Grid View (tmux-style multi-terminal) ────────────────
|
|
205
|
+
function showGridView(sessions) {
|
|
206
|
+
const connectable = sessions.filter(function(s) { return s.online && s.token; });
|
|
207
|
+
if (connectable.length === 0) return;
|
|
208
|
+
|
|
209
|
+
// Clean up previous grid
|
|
210
|
+
destroyGrid();
|
|
211
|
+
|
|
212
|
+
currentView = 'grid';
|
|
213
|
+
dashboard.classList.add('hidden');
|
|
214
|
+
terminal.classList.add('hidden');
|
|
215
|
+
termContainer.classList.add('hidden');
|
|
216
|
+
$('#input-area').classList.add('hidden');
|
|
217
|
+
|
|
218
|
+
var gridEl = document.getElementById('grid-view');
|
|
219
|
+
if (!gridEl) {
|
|
220
|
+
gridEl = document.createElement('div');
|
|
221
|
+
gridEl.id = 'grid-view';
|
|
222
|
+
document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
|
|
223
|
+
}
|
|
224
|
+
gridEl.classList.remove('hidden');
|
|
225
|
+
gridEl.innerHTML = '';
|
|
226
|
+
|
|
227
|
+
// Calculate grid dimensions
|
|
228
|
+
var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
|
|
229
|
+
gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
|
230
|
+
|
|
231
|
+
connectable.forEach(function(s) {
|
|
232
|
+
var panel = document.createElement('div');
|
|
233
|
+
panel.className = 'grid-panel';
|
|
234
|
+
|
|
235
|
+
// Header
|
|
236
|
+
var header = document.createElement('div');
|
|
237
|
+
header.className = 'grid-panel-header';
|
|
238
|
+
header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
|
|
239
|
+
'<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
|
|
240
|
+
'<span class="grid-panel-status">●</span>';
|
|
241
|
+
panel.appendChild(header);
|
|
242
|
+
|
|
243
|
+
// Terminal container
|
|
244
|
+
var termDiv = document.createElement('div');
|
|
245
|
+
termDiv.className = 'grid-panel-terminal';
|
|
246
|
+
panel.appendChild(termDiv);
|
|
247
|
+
|
|
248
|
+
gridEl.appendChild(panel);
|
|
249
|
+
|
|
250
|
+
// Create xterm instance for this panel
|
|
251
|
+
var panelXterm = new Terminal({
|
|
252
|
+
theme: {
|
|
253
|
+
background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
|
|
254
|
+
selectionBackground: '#264f78',
|
|
255
|
+
},
|
|
256
|
+
fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
|
|
257
|
+
fontSize: 11,
|
|
258
|
+
scrollback: 1000,
|
|
259
|
+
cursorBlink: true,
|
|
260
|
+
});
|
|
261
|
+
var panelFit = new FitAddon.FitAddon();
|
|
262
|
+
panelXterm.loadAddon(panelFit);
|
|
263
|
+
panelXterm.open(termDiv);
|
|
264
|
+
|
|
265
|
+
// Delay fit to ensure container has size
|
|
266
|
+
setTimeout(function() { panelFit.fit(); }, 100);
|
|
267
|
+
|
|
268
|
+
// Connect WebSocket to this session
|
|
269
|
+
var proto = 'wss:';
|
|
270
|
+
var wsUrl = s.url.replace('https://', 'wss://') + '?token=' + encodeURIComponent(s.token);
|
|
271
|
+
var panelWs = new WebSocket(wsUrl);
|
|
272
|
+
var statusDot = header.querySelector('.grid-panel-status');
|
|
273
|
+
|
|
274
|
+
panelWs.onopen = function() {
|
|
275
|
+
if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
|
|
276
|
+
// Send initial size
|
|
277
|
+
panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
|
|
278
|
+
};
|
|
279
|
+
panelWs.onclose = function() {
|
|
280
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
|
|
281
|
+
};
|
|
282
|
+
panelWs.onerror = function() {
|
|
283
|
+
if (statusDot) { statusDot.style.color = 'var(--red)'; }
|
|
284
|
+
};
|
|
285
|
+
panelWs.onmessage = function(e) {
|
|
286
|
+
try {
|
|
287
|
+
var msg = JSON.parse(e.data);
|
|
288
|
+
if (msg.type === 'pty') {
|
|
289
|
+
panelXterm.write(msg.data);
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Keyboard input from this panel → send to its session
|
|
295
|
+
panelXterm.onData(function(data) {
|
|
296
|
+
if (panelWs.readyState === WebSocket.OPEN) {
|
|
297
|
+
panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Click header to go full-screen on this session
|
|
302
|
+
header.addEventListener('click', function() {
|
|
303
|
+
window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Handle window resize for grid panels
|
|
310
|
+
window.addEventListener('resize', fitGridPanels);
|
|
311
|
+
|
|
312
|
+
// Add back button
|
|
313
|
+
if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function fitGridPanels() {
|
|
317
|
+
gridTerminals.forEach(function(gt) {
|
|
318
|
+
if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function destroyGrid() {
|
|
323
|
+
gridTerminals.forEach(function(gt) {
|
|
324
|
+
if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
|
|
325
|
+
if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
|
|
326
|
+
});
|
|
327
|
+
gridTerminals = [];
|
|
328
|
+
window.removeEventListener('resize', fitGridPanels);
|
|
329
|
+
var gridEl = document.getElementById('grid-view');
|
|
330
|
+
if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
|
|
331
|
+
}
|
|
332
|
+
|
|
191
333
|
window.toggleView = () => {
|
|
334
|
+
if (currentView === 'grid') {
|
|
335
|
+
// Grid → dashboard (list view)
|
|
336
|
+
destroyGrid();
|
|
337
|
+
currentView = 'dashboard';
|
|
338
|
+
dashboard.classList.remove('hidden');
|
|
339
|
+
$('#btn-sessions').textContent = 'Terminal';
|
|
340
|
+
loadSessions();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
192
343
|
if (currentView === 'terminal') {
|
|
193
344
|
currentView = 'dashboard';
|
|
194
345
|
terminal.classList.add('hidden');
|
|
@@ -198,6 +349,7 @@
|
|
|
198
349
|
$('#btn-sessions').textContent = 'Terminal';
|
|
199
350
|
loadSessions();
|
|
200
351
|
} else {
|
|
352
|
+
destroyGrid();
|
|
201
353
|
currentView = 'terminal';
|
|
202
354
|
dashboard.classList.add('hidden');
|
|
203
355
|
$('#input-area').classList.remove('hidden');
|
package/remote-ui/styles.css
CHANGED
|
@@ -242,10 +242,51 @@ header {
|
|
|
242
242
|
.session-card .status-dot.offline { background: var(--text-dim); }
|
|
243
243
|
.session-card .info { flex: 1; min-width: 0; }
|
|
244
244
|
.session-card .repo { color: var(--blue); font-weight: bold; font-size: 13px; }
|
|
245
|
+
.session-card .session-name { color: var(--text-bright); font-weight: bold; font-size: 14px; }
|
|
245
246
|
.session-card .branch { color: var(--text-dim); font-size: 11px; }
|
|
246
247
|
.session-card .machine { color: var(--text-dim); font-size: 11px; }
|
|
247
248
|
.session-card .arrow { color: var(--text-dim); }
|
|
248
249
|
|
|
250
|
+
/* Grid View (tmux-style multi-terminal) */
|
|
251
|
+
#grid-view {
|
|
252
|
+
flex: 1;
|
|
253
|
+
display: grid;
|
|
254
|
+
gap: 2px;
|
|
255
|
+
padding: 2px;
|
|
256
|
+
overflow: hidden;
|
|
257
|
+
background: var(--border);
|
|
258
|
+
}
|
|
259
|
+
.grid-panel {
|
|
260
|
+
display: flex;
|
|
261
|
+
flex-direction: column;
|
|
262
|
+
background: var(--bg);
|
|
263
|
+
overflow: hidden;
|
|
264
|
+
min-height: 0;
|
|
265
|
+
}
|
|
266
|
+
.grid-panel-header {
|
|
267
|
+
display: flex;
|
|
268
|
+
align-items: center;
|
|
269
|
+
gap: 6px;
|
|
270
|
+
padding: 3px 8px;
|
|
271
|
+
background: var(--bg-tool);
|
|
272
|
+
border-bottom: 1px solid var(--border);
|
|
273
|
+
flex-shrink: 0;
|
|
274
|
+
cursor: pointer;
|
|
275
|
+
font-size: 11px;
|
|
276
|
+
}
|
|
277
|
+
.grid-panel-header:hover { background: var(--border); }
|
|
278
|
+
.grid-panel-name { color: var(--blue); font-weight: bold; }
|
|
279
|
+
.grid-panel-machine { color: var(--text-dim); flex: 1; }
|
|
280
|
+
.grid-panel-status { font-size: 8px; color: var(--yellow); }
|
|
281
|
+
.grid-panel-terminal {
|
|
282
|
+
flex: 1;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
}
|
|
285
|
+
.grid-panel-terminal .xterm {
|
|
286
|
+
height: 100%;
|
|
287
|
+
padding: 2px;
|
|
288
|
+
}
|
|
289
|
+
|
|
249
290
|
/* Scrollbar */
|
|
250
291
|
::-webkit-scrollbar { width: 6px; }
|
|
251
292
|
::-webkit-scrollbar-track { background: transparent; }
|