@mjasano/devtunnel 1.1.0 → 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/CHANGELOG.md +44 -0
- package/CLAUDE.md +18 -0
- package/package.json +1 -1
- package/public/index.html +295 -38
- package/server.js +314 -6
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.2.0] - 2025-01-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Terminal tabs UI for managing multiple sessions
|
|
12
|
+
- tmux session integration (attach to existing tmux sessions)
|
|
13
|
+
- System monitor in sidebar (CPU, Memory, Uptime, Load average)
|
|
14
|
+
- Auto port detection (finds next available port if default is in use)
|
|
15
|
+
- Session detach functionality
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Sessions section moved from sidebar to terminal tabs
|
|
19
|
+
- Improved session switching with proper detach handling
|
|
20
|
+
|
|
21
|
+
## [1.1.0] - 2025-01-02
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Tab-based UI for switching between Terminal and Editor
|
|
25
|
+
- Code editor powered by Monaco Editor (VS Code engine)
|
|
26
|
+
- File explorer in sidebar with breadcrumb navigation
|
|
27
|
+
- File system API endpoints (`/api/files`, `/api/files/read`, `/api/files/write`)
|
|
28
|
+
- Multiple file tabs with unsaved changes indicator
|
|
29
|
+
- Auto-detect syntax highlighting for 20+ languages
|
|
30
|
+
- Keyboard shortcut `Ctrl+S` to save files
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- File click handling with special characters in paths
|
|
34
|
+
|
|
35
|
+
## [1.0.0] - 2025-01-01
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- Web-based terminal with xterm.js
|
|
39
|
+
- Persistent sessions (24h timeout)
|
|
40
|
+
- Multiple session management
|
|
41
|
+
- Cloudflare tunnel integration for public URL exposure
|
|
42
|
+
- Session reconnection with output buffer
|
|
43
|
+
- CLI tool (`devtunnel`) with auto-setup
|
|
44
|
+
- Docker support
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Project Rules
|
|
2
|
+
|
|
3
|
+
## Release Process
|
|
4
|
+
|
|
5
|
+
새 버전 출시 시 아래 순서를 따를 것:
|
|
6
|
+
|
|
7
|
+
1. `CHANGELOG.md` 업데이트 (변경사항 기록)
|
|
8
|
+
2. `package.json` 버전 올리기
|
|
9
|
+
3. `npm publish --access public`
|
|
10
|
+
4. `git add . && git commit && git push`
|
|
11
|
+
5. `gh release create vX.X.X` (GitHub Release 생성)
|
|
12
|
+
|
|
13
|
+
## Version Convention
|
|
14
|
+
|
|
15
|
+
- Semantic Versioning 사용 (MAJOR.MINOR.PATCH)
|
|
16
|
+
- MAJOR: 호환성 깨지는 변경
|
|
17
|
+
- MINOR: 새 기능 추가
|
|
18
|
+
- PATCH: 버그 수정
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -458,6 +458,78 @@
|
|
|
458
458
|
display: none;
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
/* Terminal Tabs */
|
|
462
|
+
.terminal-tabs {
|
|
463
|
+
display: flex;
|
|
464
|
+
background-color: #161b22;
|
|
465
|
+
border-bottom: 1px solid #30363d;
|
|
466
|
+
overflow-x: auto;
|
|
467
|
+
min-height: 35px;
|
|
468
|
+
align-items: center;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.terminal-tab {
|
|
472
|
+
display: flex;
|
|
473
|
+
align-items: center;
|
|
474
|
+
gap: 8px;
|
|
475
|
+
padding: 8px 12px;
|
|
476
|
+
font-size: 12px;
|
|
477
|
+
color: #8b949e;
|
|
478
|
+
background-color: #0d1117;
|
|
479
|
+
border-right: 1px solid #30363d;
|
|
480
|
+
cursor: pointer;
|
|
481
|
+
white-space: nowrap;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.terminal-tab:hover {
|
|
485
|
+
background-color: #161b22;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.terminal-tab.active {
|
|
489
|
+
color: #c9d1d9;
|
|
490
|
+
background-color: #161b22;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.terminal-tab-close {
|
|
494
|
+
width: 16px;
|
|
495
|
+
height: 16px;
|
|
496
|
+
display: flex;
|
|
497
|
+
align-items: center;
|
|
498
|
+
justify-content: center;
|
|
499
|
+
border-radius: 4px;
|
|
500
|
+
font-size: 14px;
|
|
501
|
+
line-height: 1;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.terminal-tab-close:hover {
|
|
505
|
+
background-color: #30363d;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.terminal-tab-new {
|
|
509
|
+
width: 28px;
|
|
510
|
+
height: 28px;
|
|
511
|
+
margin: 0 4px;
|
|
512
|
+
background-color: transparent;
|
|
513
|
+
border: 1px solid #30363d;
|
|
514
|
+
border-radius: 4px;
|
|
515
|
+
color: #8b949e;
|
|
516
|
+
font-size: 16px;
|
|
517
|
+
cursor: pointer;
|
|
518
|
+
display: flex;
|
|
519
|
+
align-items: center;
|
|
520
|
+
justify-content: center;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.terminal-tab-new:hover {
|
|
524
|
+
background-color: #21262d;
|
|
525
|
+
color: #c9d1d9;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.terminal-tab .tmux-badge {
|
|
529
|
+
color: #3fb950;
|
|
530
|
+
font-size: 10px;
|
|
531
|
+
}
|
|
532
|
+
|
|
461
533
|
.editor-tabs {
|
|
462
534
|
display: flex;
|
|
463
535
|
background-color: #161b22;
|
|
@@ -604,6 +676,67 @@
|
|
|
604
676
|
color: #484f58;
|
|
605
677
|
}
|
|
606
678
|
|
|
679
|
+
/* System Monitor */
|
|
680
|
+
.system-stats {
|
|
681
|
+
padding: 8px 12px;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.stat-row {
|
|
685
|
+
display: flex;
|
|
686
|
+
justify-content: space-between;
|
|
687
|
+
align-items: center;
|
|
688
|
+
padding: 6px 0;
|
|
689
|
+
font-size: 12px;
|
|
690
|
+
border-bottom: 1px solid #21262d;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.stat-row:last-child {
|
|
694
|
+
border-bottom: none;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.stat-label {
|
|
698
|
+
color: #8b949e;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.stat-value {
|
|
702
|
+
color: #c9d1d9;
|
|
703
|
+
font-family: monospace;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.stat-bar {
|
|
707
|
+
width: 100%;
|
|
708
|
+
height: 6px;
|
|
709
|
+
background-color: #21262d;
|
|
710
|
+
border-radius: 3px;
|
|
711
|
+
margin-top: 4px;
|
|
712
|
+
overflow: hidden;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.stat-bar-fill {
|
|
716
|
+
height: 100%;
|
|
717
|
+
border-radius: 3px;
|
|
718
|
+
transition: width 0.3s ease;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.stat-bar-fill.cpu {
|
|
722
|
+
background: linear-gradient(90deg, #58a6ff, #8b5cf6);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
.stat-bar-fill.memory {
|
|
726
|
+
background: linear-gradient(90deg, #3fb950, #58a6ff);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.stat-bar-fill.high {
|
|
730
|
+
background: linear-gradient(90deg, #d29922, #f85149);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.system-hostname {
|
|
734
|
+
font-size: 11px;
|
|
735
|
+
color: #6e7681;
|
|
736
|
+
padding: 4px 12px 8px;
|
|
737
|
+
border-bottom: 1px solid #21262d;
|
|
738
|
+
}
|
|
739
|
+
|
|
607
740
|
@keyframes slideIn {
|
|
608
741
|
from {
|
|
609
742
|
transform: translateY(20px);
|
|
@@ -665,6 +798,9 @@
|
|
|
665
798
|
|
|
666
799
|
<div class="main-container">
|
|
667
800
|
<div id="terminal-container">
|
|
801
|
+
<div class="terminal-tabs" id="terminal-tabs">
|
|
802
|
+
<button class="terminal-tab-new" onclick="createNewSession()" title="New Shell">+</button>
|
|
803
|
+
</div>
|
|
668
804
|
<div id="terminal"></div>
|
|
669
805
|
</div>
|
|
670
806
|
|
|
@@ -681,6 +817,36 @@
|
|
|
681
817
|
</div>
|
|
682
818
|
|
|
683
819
|
<div class="sidebar">
|
|
820
|
+
<!-- System Monitor Section -->
|
|
821
|
+
<div class="sidebar-section">
|
|
822
|
+
<div class="sidebar-header" onclick="toggleSection('system')">
|
|
823
|
+
<h2>System</h2>
|
|
824
|
+
</div>
|
|
825
|
+
<div id="system-content">
|
|
826
|
+
<div class="system-hostname" id="system-hostname">Loading...</div>
|
|
827
|
+
<div class="system-stats" id="system-stats">
|
|
828
|
+
<div class="stat-row">
|
|
829
|
+
<span class="stat-label">CPU</span>
|
|
830
|
+
<span class="stat-value" id="cpu-value">--%</span>
|
|
831
|
+
</div>
|
|
832
|
+
<div class="stat-bar"><div class="stat-bar-fill cpu" id="cpu-bar" style="width: 0%"></div></div>
|
|
833
|
+
<div class="stat-row" style="margin-top: 8px;">
|
|
834
|
+
<span class="stat-label">Memory</span>
|
|
835
|
+
<span class="stat-value" id="mem-value">--%</span>
|
|
836
|
+
</div>
|
|
837
|
+
<div class="stat-bar"><div class="stat-bar-fill memory" id="mem-bar" style="width: 0%"></div></div>
|
|
838
|
+
<div class="stat-row" style="margin-top: 8px;">
|
|
839
|
+
<span class="stat-label">Uptime</span>
|
|
840
|
+
<span class="stat-value" id="uptime-value">--</span>
|
|
841
|
+
</div>
|
|
842
|
+
<div class="stat-row">
|
|
843
|
+
<span class="stat-label">Load</span>
|
|
844
|
+
<span class="stat-value" id="load-value">--</span>
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
</div>
|
|
848
|
+
</div>
|
|
849
|
+
|
|
684
850
|
<!-- Files Section -->
|
|
685
851
|
<div class="sidebar-section">
|
|
686
852
|
<div class="sidebar-header" onclick="toggleSection('files')">
|
|
@@ -696,20 +862,6 @@
|
|
|
696
862
|
</div>
|
|
697
863
|
</div>
|
|
698
864
|
|
|
699
|
-
<!-- Sessions Section -->
|
|
700
|
-
<div class="sidebar-section">
|
|
701
|
-
<div class="sidebar-header" onclick="toggleSection('sessions')">
|
|
702
|
-
<h2>Sessions</h2>
|
|
703
|
-
<span class="count" id="session-count">0</span>
|
|
704
|
-
</div>
|
|
705
|
-
<div class="sidebar-content" id="sessions-content">
|
|
706
|
-
<button class="btn btn-primary btn-sm" style="width: 100%; margin-bottom: 8px;" onclick="createNewSession()">+ New Session</button>
|
|
707
|
-
<div id="session-list">
|
|
708
|
-
<div class="empty-state">No sessions</div>
|
|
709
|
-
</div>
|
|
710
|
-
</div>
|
|
711
|
-
</div>
|
|
712
|
-
|
|
713
865
|
<!-- Tunnels Section -->
|
|
714
866
|
<div class="sidebar-section">
|
|
715
867
|
<div class="sidebar-header" onclick="toggleSection('tunnels')">
|
|
@@ -759,6 +911,7 @@
|
|
|
759
911
|
|
|
760
912
|
let tunnels = [];
|
|
761
913
|
let sessions = [];
|
|
914
|
+
let tmuxSessions = [];
|
|
762
915
|
let currentSessionId = localStorage.getItem('devtunnel-session-id');
|
|
763
916
|
|
|
764
917
|
// Editor state
|
|
@@ -1120,33 +1273,119 @@
|
|
|
1120
1273
|
return date.toLocaleTimeString();
|
|
1121
1274
|
}
|
|
1122
1275
|
|
|
1123
|
-
function
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1276
|
+
function formatUptime(seconds) {
|
|
1277
|
+
const days = Math.floor(seconds / 86400);
|
|
1278
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
1279
|
+
const mins = Math.floor((seconds % 3600) / 60);
|
|
1280
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
1281
|
+
if (hours > 0) return `${hours}h ${mins}m`;
|
|
1282
|
+
return `${mins}m`;
|
|
1283
|
+
}
|
|
1126
1284
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1285
|
+
function formatBytes(bytes) {
|
|
1286
|
+
const gb = bytes / (1024 * 1024 * 1024);
|
|
1287
|
+
return gb.toFixed(1) + ' GB';
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async function updateSystemInfo() {
|
|
1291
|
+
try {
|
|
1292
|
+
const res = await fetch('/api/system');
|
|
1293
|
+
const data = await res.json();
|
|
1294
|
+
|
|
1295
|
+
document.getElementById('system-hostname').textContent =
|
|
1296
|
+
`${data.hostname} (${data.platform}/${data.arch})`;
|
|
1297
|
+
|
|
1298
|
+
// CPU
|
|
1299
|
+
const cpuValue = document.getElementById('cpu-value');
|
|
1300
|
+
const cpuBar = document.getElementById('cpu-bar');
|
|
1301
|
+
cpuValue.textContent = `${data.cpu.usage}%`;
|
|
1302
|
+
cpuBar.style.width = `${data.cpu.usage}%`;
|
|
1303
|
+
cpuBar.className = `stat-bar-fill ${data.cpu.usage > 80 ? 'high' : 'cpu'}`;
|
|
1304
|
+
|
|
1305
|
+
// Memory
|
|
1306
|
+
const memValue = document.getElementById('mem-value');
|
|
1307
|
+
const memBar = document.getElementById('mem-bar');
|
|
1308
|
+
memValue.textContent = `${data.memory.usage}% (${formatBytes(data.memory.used)}/${formatBytes(data.memory.total)})`;
|
|
1309
|
+
memBar.style.width = `${data.memory.usage}%`;
|
|
1310
|
+
memBar.className = `stat-bar-fill ${data.memory.usage > 80 ? 'high' : 'memory'}`;
|
|
1311
|
+
|
|
1312
|
+
// Uptime
|
|
1313
|
+
document.getElementById('uptime-value').textContent = formatUptime(data.uptime);
|
|
1314
|
+
|
|
1315
|
+
// Load average
|
|
1316
|
+
document.getElementById('load-value').textContent =
|
|
1317
|
+
data.loadavg.map(l => l.toFixed(2)).join(' ');
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
console.error('Failed to fetch system info:', err);
|
|
1130
1320
|
}
|
|
1321
|
+
}
|
|
1131
1322
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1323
|
+
// Update system info every 3 seconds
|
|
1324
|
+
updateSystemInfo();
|
|
1325
|
+
setInterval(updateSystemInfo, 3000);
|
|
1326
|
+
|
|
1327
|
+
function renderTerminalTabs() {
|
|
1328
|
+
const container = document.getElementById('terminal-tabs');
|
|
1329
|
+
|
|
1330
|
+
// Build tabs HTML
|
|
1331
|
+
let tabsHtml = '';
|
|
1332
|
+
|
|
1333
|
+
// Tmux sessions first
|
|
1334
|
+
tmuxSessions.forEach(session => {
|
|
1335
|
+
const isActive = currentSessionId && sessionIdDisplay.textContent === `tmux:${session.name}`;
|
|
1336
|
+
tabsHtml += `
|
|
1337
|
+
<div class="terminal-tab ${isActive ? 'active' : ''}" onclick="attachToTmux('${session.name}')">
|
|
1338
|
+
<span class="tmux-badge">tmux</span>
|
|
1339
|
+
<span>${session.name}</span>
|
|
1142
1340
|
</div>
|
|
1143
|
-
|
|
1144
|
-
|
|
1341
|
+
`;
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
// Shell sessions
|
|
1345
|
+
sessions.forEach(session => {
|
|
1346
|
+
const isActive = session.id === currentSessionId;
|
|
1347
|
+
tabsHtml += `
|
|
1348
|
+
<div class="terminal-tab ${isActive ? 'active' : ''}" onclick="attachToSession('${session.id}')">
|
|
1349
|
+
<span>${session.id.slice(0, 8)}</span>
|
|
1350
|
+
<span class="terminal-tab-close" onclick="event.stopPropagation(); killSession('${session.id}')">×</span>
|
|
1145
1351
|
</div>
|
|
1146
|
-
|
|
1147
|
-
|
|
1352
|
+
`;
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
// Add new button at the end
|
|
1356
|
+
tabsHtml += `<button class="terminal-tab-new" onclick="createNewSession()" title="New Shell">+</button>`;
|
|
1357
|
+
tabsHtml += `<button class="terminal-tab-new" onclick="refreshTmuxSessions()" title="Refresh tmux" style="font-size: 12px;">↻</button>`;
|
|
1358
|
+
|
|
1359
|
+
container.innerHTML = tabsHtml;
|
|
1148
1360
|
}
|
|
1149
1361
|
|
|
1362
|
+
function renderSessions() {
|
|
1363
|
+
renderTerminalTabs();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function renderTmuxSessions() {
|
|
1367
|
+
renderTerminalTabs();
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function attachToTmux(sessionName) {
|
|
1371
|
+
// Detach from current session first
|
|
1372
|
+
if (currentSessionId) {
|
|
1373
|
+
ws.send(JSON.stringify({ type: 'detach' }));
|
|
1374
|
+
}
|
|
1375
|
+
currentSessionId = null;
|
|
1376
|
+
localStorage.removeItem('devtunnel-session-id');
|
|
1377
|
+
term.clear();
|
|
1378
|
+
ws.send(JSON.stringify({ type: 'attach-tmux', tmuxSession: sessionName }));
|
|
1379
|
+
switchTab('terminal');
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function refreshTmuxSessions() {
|
|
1383
|
+
ws.send(JSON.stringify({ type: 'refresh-tmux' }));
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
window.attachToTmux = attachToTmux;
|
|
1387
|
+
window.refreshTmuxSessions = refreshTmuxSessions;
|
|
1388
|
+
|
|
1150
1389
|
function renderTunnels() {
|
|
1151
1390
|
const list = document.getElementById('tunnel-list');
|
|
1152
1391
|
document.getElementById('tunnel-count').textContent = tunnels.length;
|
|
@@ -1205,18 +1444,28 @@
|
|
|
1205
1444
|
}
|
|
1206
1445
|
|
|
1207
1446
|
function createNewSession() {
|
|
1447
|
+
// Detach from current session first
|
|
1448
|
+
if (currentSessionId) {
|
|
1449
|
+
ws.send(JSON.stringify({ type: 'detach' }));
|
|
1450
|
+
}
|
|
1208
1451
|
currentSessionId = null;
|
|
1209
1452
|
localStorage.removeItem('devtunnel-session-id');
|
|
1210
1453
|
term.clear();
|
|
1211
1454
|
ws.send(JSON.stringify({ type: 'attach', sessionId: null }));
|
|
1455
|
+
switchTab('terminal');
|
|
1212
1456
|
}
|
|
1213
1457
|
|
|
1214
1458
|
function attachToSession(sessionId) {
|
|
1215
1459
|
if (sessionId === currentSessionId) return;
|
|
1460
|
+
// Detach from current session first
|
|
1461
|
+
if (currentSessionId) {
|
|
1462
|
+
ws.send(JSON.stringify({ type: 'detach' }));
|
|
1463
|
+
}
|
|
1216
1464
|
currentSessionId = sessionId;
|
|
1217
1465
|
localStorage.setItem('devtunnel-session-id', sessionId);
|
|
1218
1466
|
term.clear();
|
|
1219
1467
|
ws.send(JSON.stringify({ type: 'attach', sessionId }));
|
|
1468
|
+
switchTab('terminal');
|
|
1220
1469
|
}
|
|
1221
1470
|
|
|
1222
1471
|
function killSession(sessionId) {
|
|
@@ -1245,8 +1494,8 @@
|
|
|
1245
1494
|
statusText.textContent = 'Connected';
|
|
1246
1495
|
reconnectOverlay.classList.remove('show');
|
|
1247
1496
|
|
|
1248
|
-
//
|
|
1249
|
-
|
|
1497
|
+
// Don't auto-attach - let user choose session manually
|
|
1498
|
+
term.write('\x1b[90mClick + to create a new session.\x1b[0m\r\n');
|
|
1250
1499
|
};
|
|
1251
1500
|
|
|
1252
1501
|
ws.onmessage = (event) => {
|
|
@@ -1258,7 +1507,7 @@
|
|
|
1258
1507
|
currentSessionId = msg.sessionId;
|
|
1259
1508
|
localStorage.setItem('devtunnel-session-id', msg.sessionId);
|
|
1260
1509
|
sessionIndicator.style.display = 'flex';
|
|
1261
|
-
sessionIdDisplay.textContent = msg.sessionId.slice(0, 8);
|
|
1510
|
+
sessionIdDisplay.textContent = msg.tmuxSession ? `tmux:${msg.tmuxSession}` : msg.sessionId.slice(0, 8);
|
|
1262
1511
|
|
|
1263
1512
|
// Send initial size
|
|
1264
1513
|
ws.send(JSON.stringify({
|
|
@@ -1267,7 +1516,10 @@
|
|
|
1267
1516
|
rows: term.rows
|
|
1268
1517
|
}));
|
|
1269
1518
|
|
|
1270
|
-
|
|
1519
|
+
// Update terminal tabs to show active state
|
|
1520
|
+
renderTerminalTabs();
|
|
1521
|
+
|
|
1522
|
+
showToast(msg.tmuxSession ? `Attached to tmux: ${msg.tmuxSession}` : 'Session attached');
|
|
1271
1523
|
break;
|
|
1272
1524
|
|
|
1273
1525
|
case 'output':
|
|
@@ -1288,6 +1540,11 @@
|
|
|
1288
1540
|
renderTunnels();
|
|
1289
1541
|
break;
|
|
1290
1542
|
|
|
1543
|
+
case 'tmux-sessions':
|
|
1544
|
+
tmuxSessions = msg.data;
|
|
1545
|
+
renderTmuxSessions();
|
|
1546
|
+
break;
|
|
1547
|
+
|
|
1291
1548
|
case 'tunnel-created':
|
|
1292
1549
|
showToast(`Tunnel created for port ${msg.data.port}`);
|
|
1293
1550
|
createTunnelBtn.disabled = false;
|
package/server.js
CHANGED
|
@@ -30,6 +30,98 @@ function generateSessionId() {
|
|
|
30
30
|
return crypto.randomBytes(16).toString('hex');
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Get list of tmux sessions
|
|
34
|
+
async function getTmuxSessions() {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const tmux = spawn('tmux', ['list-sessions', '-F', '#{session_name}:#{session_windows}:#{session_attached}']);
|
|
37
|
+
let output = '';
|
|
38
|
+
|
|
39
|
+
tmux.stdout.on('data', (data) => {
|
|
40
|
+
output += data.toString();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
tmux.on('close', (code) => {
|
|
44
|
+
if (code !== 0 || !output.trim()) {
|
|
45
|
+
resolve([]);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sessions = output.trim().split('\n').map(line => {
|
|
50
|
+
const [name, windows, attached] = line.split(':');
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
windows: parseInt(windows) || 1,
|
|
54
|
+
attached: attached === '1'
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
resolve(sessions);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
tmux.on('error', () => {
|
|
62
|
+
resolve([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Create session attached to tmux
|
|
68
|
+
function createTmuxSession(tmuxSessionName) {
|
|
69
|
+
const id = `tmux-${tmuxSessionName}-${Date.now()}`;
|
|
70
|
+
|
|
71
|
+
const ptyProcess = pty.spawn('tmux', ['attach-session', '-t', tmuxSessionName], {
|
|
72
|
+
name: 'xterm-256color',
|
|
73
|
+
cols: 80,
|
|
74
|
+
rows: 24,
|
|
75
|
+
cwd: process.env.WORKSPACE || os.homedir(),
|
|
76
|
+
env: { ...process.env, TERM: 'xterm-256color' }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const session = {
|
|
80
|
+
id,
|
|
81
|
+
pty: ptyProcess,
|
|
82
|
+
outputBuffer: '',
|
|
83
|
+
clients: new Set(),
|
|
84
|
+
lastAccess: Date.now(),
|
|
85
|
+
createdAt: Date.now(),
|
|
86
|
+
alive: true,
|
|
87
|
+
type: 'tmux',
|
|
88
|
+
tmuxSession: tmuxSessionName
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
ptyProcess.onData((data) => {
|
|
92
|
+
session.outputBuffer += data;
|
|
93
|
+
if (session.outputBuffer.length > OUTPUT_BUFFER_SIZE) {
|
|
94
|
+
session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_SIZE);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
session.clients.forEach(ws => {
|
|
98
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
99
|
+
ws.send(JSON.stringify({ type: 'output', data }));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
105
|
+
console.log(`Tmux session ${tmuxSessionName} detached with code ${exitCode}`);
|
|
106
|
+
session.alive = false;
|
|
107
|
+
|
|
108
|
+
session.clients.forEach(ws => {
|
|
109
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
110
|
+
ws.send(JSON.stringify({ type: 'exit', exitCode, signal }));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
sessions.delete(id);
|
|
116
|
+
broadcastSessionList();
|
|
117
|
+
}, 5000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
sessions.set(id, session);
|
|
121
|
+
console.log(`Tmux session attached: ${tmuxSessionName}`);
|
|
122
|
+
return session;
|
|
123
|
+
}
|
|
124
|
+
|
|
33
125
|
// Create or get existing session
|
|
34
126
|
function getOrCreateSession(sessionId = null) {
|
|
35
127
|
// If sessionId provided and exists, return it
|
|
@@ -120,7 +212,9 @@ function broadcastSessionList() {
|
|
|
120
212
|
createdAt: s.createdAt,
|
|
121
213
|
lastAccess: s.lastAccess,
|
|
122
214
|
alive: s.alive,
|
|
123
|
-
clients: s.clients.size
|
|
215
|
+
clients: s.clients.size,
|
|
216
|
+
type: s.type || 'shell',
|
|
217
|
+
tmuxSession: s.tmuxSession
|
|
124
218
|
}));
|
|
125
219
|
|
|
126
220
|
wss.clients.forEach(client => {
|
|
@@ -234,7 +328,7 @@ function stopTunnel(id) {
|
|
|
234
328
|
}
|
|
235
329
|
|
|
236
330
|
// WebSocket handling
|
|
237
|
-
wss.on('connection', (ws) => {
|
|
331
|
+
wss.on('connection', async (ws) => {
|
|
238
332
|
console.log('Client connected');
|
|
239
333
|
|
|
240
334
|
let currentSession = null;
|
|
@@ -254,16 +348,38 @@ wss.on('connection', (ws) => {
|
|
|
254
348
|
createdAt: s.createdAt,
|
|
255
349
|
lastAccess: s.lastAccess,
|
|
256
350
|
alive: s.alive,
|
|
257
|
-
clients: s.clients.size
|
|
351
|
+
clients: s.clients.size,
|
|
352
|
+
type: s.type || 'shell',
|
|
353
|
+
tmuxSession: s.tmuxSession
|
|
258
354
|
}));
|
|
259
355
|
ws.send(JSON.stringify({ type: 'sessions', data: sessionList }));
|
|
260
356
|
|
|
357
|
+
// Send tmux sessions list
|
|
358
|
+
const tmuxSessions = await getTmuxSessions();
|
|
359
|
+
ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
|
|
360
|
+
|
|
261
361
|
ws.on('message', (message) => {
|
|
262
362
|
try {
|
|
263
363
|
const msg = JSON.parse(message.toString());
|
|
264
364
|
|
|
265
365
|
switch (msg.type) {
|
|
366
|
+
case 'detach':
|
|
367
|
+
// Detach from current session
|
|
368
|
+
if (currentSession) {
|
|
369
|
+
currentSession.clients.delete(ws);
|
|
370
|
+
currentSession.lastAccess = Date.now();
|
|
371
|
+
broadcastSessionList();
|
|
372
|
+
console.log(`Client detached from session ${currentSession.id}`);
|
|
373
|
+
currentSession = null;
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
|
|
266
377
|
case 'attach':
|
|
378
|
+
// Detach from current session first if any
|
|
379
|
+
if (currentSession) {
|
|
380
|
+
currentSession.clients.delete(ws);
|
|
381
|
+
currentSession.lastAccess = Date.now();
|
|
382
|
+
}
|
|
267
383
|
// Attach to existing or new session
|
|
268
384
|
try {
|
|
269
385
|
currentSession = getOrCreateSession(msg.sessionId);
|
|
@@ -339,6 +455,43 @@ wss.on('connection', (ws) => {
|
|
|
339
455
|
}
|
|
340
456
|
break;
|
|
341
457
|
|
|
458
|
+
case 'attach-tmux':
|
|
459
|
+
if (msg.tmuxSession) {
|
|
460
|
+
try {
|
|
461
|
+
// Detach from current session if any
|
|
462
|
+
if (currentSession) {
|
|
463
|
+
currentSession.clients.delete(ws);
|
|
464
|
+
currentSession.lastAccess = Date.now();
|
|
465
|
+
broadcastSessionList();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
currentSession = createTmuxSession(msg.tmuxSession);
|
|
469
|
+
currentSession.clients.add(ws);
|
|
470
|
+
|
|
471
|
+
ws.send(JSON.stringify({
|
|
472
|
+
type: 'attached',
|
|
473
|
+
sessionId: currentSession.id,
|
|
474
|
+
alive: currentSession.alive,
|
|
475
|
+
tmuxSession: msg.tmuxSession
|
|
476
|
+
}));
|
|
477
|
+
|
|
478
|
+
broadcastSessionList();
|
|
479
|
+
console.log(`Client attached to tmux session: ${msg.tmuxSession}`);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
ws.send(JSON.stringify({
|
|
482
|
+
type: 'error',
|
|
483
|
+
message: err.message
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
|
|
489
|
+
case 'refresh-tmux':
|
|
490
|
+
getTmuxSessions().then(tmuxSessions => {
|
|
491
|
+
ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
|
|
492
|
+
});
|
|
493
|
+
break;
|
|
494
|
+
|
|
342
495
|
default:
|
|
343
496
|
console.log('Unknown message type:', msg.type);
|
|
344
497
|
}
|
|
@@ -365,6 +518,119 @@ wss.on('connection', (ws) => {
|
|
|
365
518
|
});
|
|
366
519
|
});
|
|
367
520
|
|
|
521
|
+
// System Monitoring API
|
|
522
|
+
async function getMemoryInfo() {
|
|
523
|
+
const totalMem = os.totalmem();
|
|
524
|
+
|
|
525
|
+
// macOS: use vm_stat for accurate memory info
|
|
526
|
+
if (os.platform() === 'darwin') {
|
|
527
|
+
return new Promise((resolve) => {
|
|
528
|
+
const vmstat = spawn('vm_stat');
|
|
529
|
+
let output = '';
|
|
530
|
+
|
|
531
|
+
vmstat.stdout.on('data', (data) => {
|
|
532
|
+
output += data.toString();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
vmstat.on('close', () => {
|
|
536
|
+
try {
|
|
537
|
+
const pageSize = 16384; // macOS default page size (16KB on Apple Silicon)
|
|
538
|
+
const lines = output.split('\n');
|
|
539
|
+
|
|
540
|
+
let free = 0, active = 0, inactive = 0, wired = 0, compressed = 0, purgeable = 0;
|
|
541
|
+
|
|
542
|
+
lines.forEach(line => {
|
|
543
|
+
const match = line.match(/^(.+):\s+(\d+)/);
|
|
544
|
+
if (match) {
|
|
545
|
+
const value = parseInt(match[2]) * pageSize;
|
|
546
|
+
const key = match[1].toLowerCase();
|
|
547
|
+
if (key.includes('free')) free = value;
|
|
548
|
+
else if (key.includes('active')) active = value;
|
|
549
|
+
else if (key.includes('inactive')) inactive = value;
|
|
550
|
+
else if (key.includes('wired')) wired = value;
|
|
551
|
+
else if (key.includes('compressed')) compressed = value;
|
|
552
|
+
else if (key.includes('purgeable')) purgeable = value;
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// App Memory = Active + Wired + Compressed (what Activity Monitor shows)
|
|
557
|
+
const appMemory = active + wired + compressed;
|
|
558
|
+
const usage = Math.round((appMemory / totalMem) * 100);
|
|
559
|
+
|
|
560
|
+
resolve({
|
|
561
|
+
total: totalMem,
|
|
562
|
+
used: appMemory,
|
|
563
|
+
free: totalMem - appMemory,
|
|
564
|
+
usage
|
|
565
|
+
});
|
|
566
|
+
} catch {
|
|
567
|
+
// Fallback to os.freemem()
|
|
568
|
+
const freeMem = os.freemem();
|
|
569
|
+
resolve({
|
|
570
|
+
total: totalMem,
|
|
571
|
+
used: totalMem - freeMem,
|
|
572
|
+
free: freeMem,
|
|
573
|
+
usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
vmstat.on('error', () => {
|
|
579
|
+
const freeMem = os.freemem();
|
|
580
|
+
resolve({
|
|
581
|
+
total: totalMem,
|
|
582
|
+
used: totalMem - freeMem,
|
|
583
|
+
free: freeMem,
|
|
584
|
+
usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Other platforms: use os.freemem()
|
|
591
|
+
const freeMem = os.freemem();
|
|
592
|
+
return {
|
|
593
|
+
total: totalMem,
|
|
594
|
+
used: totalMem - freeMem,
|
|
595
|
+
free: freeMem,
|
|
596
|
+
usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function getSystemInfo() {
|
|
601
|
+
const cpus = os.cpus();
|
|
602
|
+
const memory = await getMemoryInfo();
|
|
603
|
+
|
|
604
|
+
// Calculate CPU usage
|
|
605
|
+
let totalIdle = 0;
|
|
606
|
+
let totalTick = 0;
|
|
607
|
+
cpus.forEach(cpu => {
|
|
608
|
+
for (const type in cpu.times) {
|
|
609
|
+
totalTick += cpu.times[type];
|
|
610
|
+
}
|
|
611
|
+
totalIdle += cpu.times.idle;
|
|
612
|
+
});
|
|
613
|
+
const cpuUsage = Math.round((1 - totalIdle / totalTick) * 100);
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
hostname: os.hostname(),
|
|
617
|
+
platform: os.platform(),
|
|
618
|
+
arch: os.arch(),
|
|
619
|
+
uptime: os.uptime(),
|
|
620
|
+
cpu: {
|
|
621
|
+
model: cpus[0]?.model || 'Unknown',
|
|
622
|
+
cores: cpus.length,
|
|
623
|
+
usage: cpuUsage
|
|
624
|
+
},
|
|
625
|
+
memory,
|
|
626
|
+
loadavg: os.loadavg()
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
app.get('/api/system', async (req, res) => {
|
|
631
|
+
res.json(await getSystemInfo());
|
|
632
|
+
});
|
|
633
|
+
|
|
368
634
|
// File System API
|
|
369
635
|
const WORKSPACE_ROOT = process.env.WORKSPACE || os.homedir();
|
|
370
636
|
|
|
@@ -569,9 +835,51 @@ app.get('/health', (req, res) => {
|
|
|
569
835
|
});
|
|
570
836
|
});
|
|
571
837
|
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
838
|
+
const DEFAULT_PORT = process.env.PORT || 3000;
|
|
839
|
+
|
|
840
|
+
function findAvailablePort(startPort, maxAttempts = 10) {
|
|
841
|
+
return new Promise((resolve, reject) => {
|
|
842
|
+
let port = startPort;
|
|
843
|
+
let attempts = 0;
|
|
844
|
+
|
|
845
|
+
const tryPort = () => {
|
|
846
|
+
if (attempts >= maxAttempts) {
|
|
847
|
+
reject(new Error(`No available port found after ${maxAttempts} attempts`));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const testServer = require('net').createServer();
|
|
852
|
+
testServer.once('error', (err) => {
|
|
853
|
+
if (err.code === 'EADDRINUSE') {
|
|
854
|
+
console.log(`Port ${port} is in use, trying ${port + 1}...`);
|
|
855
|
+
port++;
|
|
856
|
+
attempts++;
|
|
857
|
+
tryPort();
|
|
858
|
+
} else {
|
|
859
|
+
reject(err);
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
testServer.once('listening', () => {
|
|
864
|
+
testServer.close(() => {
|
|
865
|
+
resolve(port);
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
testServer.listen(port, '0.0.0.0');
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
tryPort();
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
findAvailablePort(DEFAULT_PORT).then((PORT) => {
|
|
877
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
878
|
+
console.log(`DevTunnel server running on http://localhost:${PORT}`);
|
|
879
|
+
});
|
|
880
|
+
}).catch((err) => {
|
|
881
|
+
console.error('Failed to start server:', err.message);
|
|
882
|
+
process.exit(1);
|
|
575
883
|
});
|
|
576
884
|
|
|
577
885
|
// Graceful shutdown
|