mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat: add tunnel status indicator with control panel in header
Green pulsing dot in the desktop header shows when Cloudflare tunnel is active. Clicking opens a dropdown panel with tunnel URL, remote client count, auth sessions, and start/stop/QR/revoke controls. Detects tunnel clients via Cf-Connecting-Ip header to exclude local connections from the count. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -110,7 +110,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||||
| **Frontend** | `src/web/public/app.js` ★ (~12.1K lines) + 9 JS modules (incl. `sw.js` service worker) | |
|
| **Frontend** | `src/web/public/app.js` ★ (~12.1K lines) + 9 JS modules (incl. `sw.js` service worker) | |
|
||||||
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
|
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||||
|
|
||||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export interface EventPort {
|
|||||||
batchTerminalData(sessionId: string, data: string): void;
|
batchTerminalData(sessionId: string, data: string): void;
|
||||||
broadcastSessionStateDebounced(sessionId: string): void;
|
broadcastSessionStateDebounced(sessionId: string): void;
|
||||||
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
|
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
|
||||||
|
getSseClientCount(): number;
|
||||||
}
|
}
|
||||||
|
|||||||
+224
-2
@@ -351,6 +351,9 @@ class CodemanApp {
|
|||||||
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
||||||
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||||
|
|
||||||
|
// Tunnel indicator state
|
||||||
|
this._tunnelUrl = null;
|
||||||
|
|
||||||
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
||||||
this.tabAlerts = new Map();
|
this.tabAlerts = new Map();
|
||||||
|
|
||||||
@@ -519,6 +522,8 @@ class CodemanApp {
|
|||||||
});
|
});
|
||||||
// Register service worker for push notifications
|
// Register service worker for push notifications
|
||||||
this.registerServiceWorker();
|
this.registerServiceWorker();
|
||||||
|
// Fetch tunnel status for header indicator (desktop only)
|
||||||
|
this.loadTunnelStatus();
|
||||||
// Share a single settings fetch between both consumers
|
// Share a single settings fetch between both consumers
|
||||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
|
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
this.loadQuickStartCases(null, settingsPromise);
|
this.loadQuickStartCases(null, settingsPromise);
|
||||||
@@ -2617,8 +2622,10 @@ class CodemanApp {
|
|||||||
// Tunnel
|
// Tunnel
|
||||||
_onTunnelStarted(data) {
|
_onTunnelStarted(data) {
|
||||||
console.log('[Tunnel] Started:', data.url);
|
console.log('[Tunnel] Started:', data.url);
|
||||||
|
this._tunnelUrl = data.url;
|
||||||
this._dismissTunnelConnecting();
|
this._dismissTunnelConnecting();
|
||||||
this._updateTunnelUrlDisplay(data.url);
|
this._updateTunnelUrlDisplay(data.url);
|
||||||
|
this._updateTunnelIndicator(true);
|
||||||
const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible');
|
const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible');
|
||||||
if (welcomeVisible) {
|
if (welcomeVisible) {
|
||||||
// On welcome screen: QR appears inline, expanded first
|
// On welcome screen: QR appears inline, expanded first
|
||||||
@@ -2634,9 +2641,12 @@ class CodemanApp {
|
|||||||
|
|
||||||
_onTunnelStopped() {
|
_onTunnelStopped() {
|
||||||
console.log('[Tunnel] Stopped');
|
console.log('[Tunnel] Stopped');
|
||||||
|
this._tunnelUrl = null;
|
||||||
this._dismissTunnelConnecting();
|
this._dismissTunnelConnecting();
|
||||||
this._updateTunnelUrlDisplay(null);
|
this._updateTunnelUrlDisplay(null);
|
||||||
this._updateWelcomeTunnelBtn(false);
|
this._updateWelcomeTunnelBtn(false);
|
||||||
|
this._updateTunnelIndicator(false);
|
||||||
|
this.closeTunnelPanel();
|
||||||
this.closeTunnelQR();
|
this.closeTunnelQR();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6471,11 +6481,15 @@ class CodemanApp {
|
|||||||
const res = await fetch('/api/tunnel/status');
|
const res = await fetch('/api/tunnel/status');
|
||||||
const status = await res.json();
|
const status = await res.json();
|
||||||
const active = status.running && status.url;
|
const active = status.running && status.url;
|
||||||
this._updateTunnelUrlDisplay(active ? status.url : null);
|
this._tunnelUrl = active ? status.url : null;
|
||||||
this._updateWelcomeTunnelBtn(!!active, active ? status.url : null);
|
this._updateTunnelUrlDisplay(this._tunnelUrl);
|
||||||
|
this._updateWelcomeTunnelBtn(!!active, this._tunnelUrl);
|
||||||
|
this._updateTunnelIndicator(!!active);
|
||||||
} catch {
|
} catch {
|
||||||
|
this._tunnelUrl = null;
|
||||||
this._updateTunnelUrlDisplay(null);
|
this._updateTunnelUrlDisplay(null);
|
||||||
this._updateWelcomeTunnelBtn(false);
|
this._updateWelcomeTunnelBtn(false);
|
||||||
|
this._updateTunnelIndicator(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6803,6 +6817,214 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Tunnel Header Indicator & Panel (desktop only)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_updateTunnelIndicator(active) {
|
||||||
|
if (MobileDetection.getDeviceType() === 'mobile') return;
|
||||||
|
const indicator = document.getElementById('tunnelIndicator');
|
||||||
|
if (!indicator) return;
|
||||||
|
indicator.style.display = active ? 'flex' : 'none';
|
||||||
|
indicator.classList.remove('connecting');
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTunnelPanel() {
|
||||||
|
const existing = document.getElementById('tunnelPanel');
|
||||||
|
if (existing) {
|
||||||
|
this.closeTunnelPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._openTunnelPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _openTunnelPanel() {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'tunnel-panel';
|
||||||
|
panel.id = 'tunnelPanel';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="tunnel-panel-header">
|
||||||
|
<h3>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||||
|
Cloudflare Tunnel
|
||||||
|
<span class="tunnel-panel-status" id="tunnelPanelStatus">Loading...</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="tunnel-panel-body" id="tunnelPanelBody">
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);padding:8px 0">Loading...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
this._tunnelPanelClickHandler = (e) => {
|
||||||
|
if (!panel.contains(e.target) && e.target.id !== 'tunnelIndicator' && !e.target.closest('.tunnel-indicator')) {
|
||||||
|
this.closeTunnelPanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', this._tunnelPanelClickHandler), 0);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
this._tunnelPanelEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelPanel(); };
|
||||||
|
document.addEventListener('keydown', this._tunnelPanelEscHandler);
|
||||||
|
|
||||||
|
// Fetch tunnel info
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tunnel/info');
|
||||||
|
const info = await res.json();
|
||||||
|
this._renderTunnelPanel(info);
|
||||||
|
} catch {
|
||||||
|
const body = document.getElementById('tunnelPanelBody');
|
||||||
|
if (body) body.innerHTML = '<div style="font-size:12px;color:var(--red);padding:8px 0">Failed to load tunnel info</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderTunnelPanel(info) {
|
||||||
|
const statusEl = document.getElementById('tunnelPanelStatus');
|
||||||
|
const body = document.getElementById('tunnelPanelBody');
|
||||||
|
if (!statusEl || !body) return;
|
||||||
|
|
||||||
|
statusEl.textContent = info.running ? 'Connected' : 'Offline';
|
||||||
|
statusEl.className = 'tunnel-panel-status' + (info.running ? '' : ' offline');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// URL section
|
||||||
|
if (info.url) {
|
||||||
|
html += `
|
||||||
|
<div class="tunnel-panel-section">
|
||||||
|
<div class="tunnel-panel-label">URL</div>
|
||||||
|
<div class="tunnel-panel-url" id="tunnelPanelUrl" title="Click to copy">${escapeHtml(info.url)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients section
|
||||||
|
html += `
|
||||||
|
<div class="tunnel-panel-section">
|
||||||
|
<div class="tunnel-panel-label">Connections</div>
|
||||||
|
<div class="tunnel-panel-stat">
|
||||||
|
<span>Remote Clients</span>
|
||||||
|
<span class="tunnel-panel-stat-value">${info.sseClients}</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (info.authEnabled) {
|
||||||
|
html += `
|
||||||
|
<div class="tunnel-panel-stat">
|
||||||
|
<span>Auth Sessions</span>
|
||||||
|
<span class="tunnel-panel-stat-value">${info.authSessions.length}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Auth sessions detail
|
||||||
|
if (info.authEnabled && info.authSessions.length > 0) {
|
||||||
|
html += '<div class="tunnel-panel-section"><div class="tunnel-panel-label">Authenticated Devices</div>';
|
||||||
|
for (const s of info.authSessions) {
|
||||||
|
const ua = s.ua || 'Unknown';
|
||||||
|
const browser = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser';
|
||||||
|
const ago = this._formatTimeAgo(s.createdAt);
|
||||||
|
html += `
|
||||||
|
<div class="tunnel-panel-session">
|
||||||
|
<span class="tunnel-panel-session-dot"></span>
|
||||||
|
<span class="tunnel-panel-session-info" title="${escapeHtml(ua)}">${escapeHtml(browser)} · ${escapeHtml(s.ip)} · ${ago}</span>
|
||||||
|
<span class="tunnel-panel-session-method">${s.method}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
html += '<div class="tunnel-panel-actions">';
|
||||||
|
if (info.running) {
|
||||||
|
html += `
|
||||||
|
<button class="tunnel-panel-btn btn-qr" onclick="app.showTunnelQR();app.closeTunnelPanel()">QR Code</button>
|
||||||
|
<button class="tunnel-panel-btn btn-stop" onclick="app._tunnelPanelToggle(false)">Stop Tunnel</button>`;
|
||||||
|
} else {
|
||||||
|
html += `<button class="tunnel-panel-btn btn-start" onclick="app._tunnelPanelToggle(true)">Start Tunnel</button>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Revoke all sessions button
|
||||||
|
if (info.authEnabled && info.authSessions.length > 0) {
|
||||||
|
html += `
|
||||||
|
<div style="padding-top:8px">
|
||||||
|
<button class="tunnel-panel-btn btn-revoke" style="width:100%" onclick="app._tunnelPanelRevokeAll()">Revoke All Sessions</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = html;
|
||||||
|
|
||||||
|
// Bind URL copy handler
|
||||||
|
const urlEl = document.getElementById('tunnelPanelUrl');
|
||||||
|
if (urlEl) {
|
||||||
|
urlEl.onclick = () => {
|
||||||
|
navigator.clipboard.writeText(info.url).then(() => this.showToast('Tunnel URL copied', 'success'));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatTimeAgo(timestamp) {
|
||||||
|
const diff = Date.now() - timestamp;
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _tunnelPanelToggle(enable) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tunnelEnabled: enable }),
|
||||||
|
});
|
||||||
|
if (enable) {
|
||||||
|
this._updateTunnelIndicator(false);
|
||||||
|
const indicator = document.getElementById('tunnelIndicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.style.display = 'flex';
|
||||||
|
indicator.classList.add('connecting');
|
||||||
|
}
|
||||||
|
this.showToast('Tunnel starting...', 'info');
|
||||||
|
this._showTunnelConnecting();
|
||||||
|
this._pollTunnelStatus();
|
||||||
|
} else {
|
||||||
|
this.showToast('Tunnel stopped', 'info');
|
||||||
|
}
|
||||||
|
this.closeTunnelPanel();
|
||||||
|
} catch {
|
||||||
|
this.showToast('Failed to toggle tunnel', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _tunnelPanelRevokeAll() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||||
|
this.showToast('All sessions revoked', 'success');
|
||||||
|
// Refresh panel
|
||||||
|
const res = await fetch('/api/tunnel/info');
|
||||||
|
const info = await res.json();
|
||||||
|
this._renderTunnelPanel(info);
|
||||||
|
} catch {
|
||||||
|
this.showToast('Failed to revoke sessions', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTunnelPanel() {
|
||||||
|
const panel = document.getElementById('tunnelPanel');
|
||||||
|
if (panel) panel.remove();
|
||||||
|
if (this._tunnelPanelClickHandler) {
|
||||||
|
document.removeEventListener('click', this._tunnelPanelClickHandler);
|
||||||
|
this._tunnelPanelClickHandler = null;
|
||||||
|
}
|
||||||
|
if (this._tunnelPanelEscHandler) {
|
||||||
|
document.removeEventListener('keydown', this._tunnelPanelEscHandler);
|
||||||
|
this._tunnelPanelEscHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toggleDeepgramKeyVisibility() {
|
toggleDeepgramKeyVisibility() {
|
||||||
const input = document.getElementById('voiceDeepgramKey');
|
const input = document.getElementById('voiceDeepgramKey');
|
||||||
const btn = document.getElementById('voiceKeyToggleBtn');
|
const btn = document.getElementById('voiceKeyToggleBtn');
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||||
|
<span class="tunnel-dot"></span>
|
||||||
|
</button>
|
||||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||||
<span class="connection-dot" id="connectionDot"></span>
|
<span class="connection-dot" id="connectionDot"></span>
|
||||||
<span class="connection-text" id="connectionText"></span>
|
<span class="connection-text" id="connectionText"></span>
|
||||||
|
|||||||
@@ -444,6 +444,249 @@ body {
|
|||||||
50% { opacity: 0.3; }
|
50% { opacity: 0.3; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tunnel Indicator */
|
||||||
|
.tunnel-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-indicator:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4);
|
||||||
|
animation: tunnel-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-indicator.connecting .tunnel-dot {
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4);
|
||||||
|
animation: tunnel-glow-amber 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tunnel-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 8px #22c55e, 0 0 18px rgba(34, 197, 94, 0.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tunnel-glow-amber {
|
||||||
|
0%, 100% { box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4); opacity: 1; }
|
||||||
|
50% { box-shadow: 0 0 4px #f59e0b; opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tunnel Panel Dropdown */
|
||||||
|
.tunnel-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 36px;
|
||||||
|
right: 12px;
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-status.offline {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-body {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-url {
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-hover);
|
||||||
|
word-break: break-all;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-url:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-stat-value {
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-session {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-session-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-session-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-session-method {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn.btn-start {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn.btn-start:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn.btn-stop {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn.btn-stop:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn.btn-qr {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn.btn-qr:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn.btn-revoke {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-btn.btn-revoke:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-panel-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.header-tokens {
|
.header-tokens {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const noopEventPort: EventPort = {
|
|||||||
batchTerminalData: () => {},
|
batchTerminalData: () => {},
|
||||||
broadcastSessionStateDebounced: () => {},
|
broadcastSessionStateDebounced: () => {},
|
||||||
batchTaskUpdate: () => {},
|
batchTaskUpdate: () => {},
|
||||||
|
getSseClientCount: () => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerRespawnRoutes(
|
export function registerRespawnRoutes(
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ export function registerSystemRoutes(
|
|||||||
|
|
||||||
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
|
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
|
||||||
|
|
||||||
|
app.get('/api/tunnel/info', async () => {
|
||||||
|
const status = ctx.tunnelManager.getStatus();
|
||||||
|
const sseClients = ctx.getSseClientCount();
|
||||||
|
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
|
||||||
|
if (ctx.authSessions) {
|
||||||
|
for (const [, record] of ctx.authSessions) {
|
||||||
|
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
sseClients,
|
||||||
|
authEnabled: !!process.env.CODEMAN_PASSWORD,
|
||||||
|
authSessions: sessions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/tunnel/qr', async (_req, reply) => {
|
app.get('/api/tunnel/qr', async (_req, reply) => {
|
||||||
const url = ctx.tunnelManager.getUrl();
|
const url = ctx.tunnelManager.getUrl();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ export class WebServer extends EventEmitter {
|
|||||||
* or `null` meaning "receive all events" (backwards-compatible default).
|
* or `null` meaning "receive all events" (backwards-compatible default).
|
||||||
*/
|
*/
|
||||||
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
|
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
|
||||||
|
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
|
||||||
|
private remoteSseClients: Set<FastifyReply> = new Set();
|
||||||
/** Clients with backpressure — skip writes until 'drain' fires */
|
/** Clients with backpressure — skip writes until 'drain' fires */
|
||||||
private backpressuredClients: Set<FastifyReply> = new Set();
|
private backpressuredClients: Set<FastifyReply> = new Set();
|
||||||
private store = getStore();
|
private store = getStore();
|
||||||
@@ -505,6 +507,7 @@ export class WebServer extends EventEmitter {
|
|||||||
batchTerminalData: this.batchTerminalData.bind(this),
|
batchTerminalData: this.batchTerminalData.bind(this),
|
||||||
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
||||||
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
||||||
|
getSseClientCount: () => this.remoteSseClients.size,
|
||||||
// RespawnPort
|
// RespawnPort
|
||||||
respawnControllers: this.respawnControllers,
|
respawnControllers: this.respawnControllers,
|
||||||
respawnTimers: this.respawnTimers,
|
respawnTimers: this.respawnTimers,
|
||||||
@@ -620,6 +623,12 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
this.sseClients.set(reply, sessionFilter);
|
this.sseClients.set(reply, sessionFilter);
|
||||||
|
|
||||||
|
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
||||||
|
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
||||||
|
if (req.headers['cf-connecting-ip']) {
|
||||||
|
this.remoteSseClients.add(reply);
|
||||||
|
}
|
||||||
|
|
||||||
// Send initial state
|
// Send initial state
|
||||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||||
// Buffers are fetched on-demand when switching tabs
|
// Buffers are fetched on-demand when switching tabs
|
||||||
@@ -636,6 +645,7 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
req.raw.on('close', () => {
|
req.raw.on('close', () => {
|
||||||
this.sseClients.delete(reply);
|
this.sseClients.delete(reply);
|
||||||
|
this.remoteSseClients.delete(reply);
|
||||||
this.backpressuredClients.delete(reply);
|
this.backpressuredClients.delete(reply);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1947,6 +1957,7 @@ export class WebServer extends EventEmitter {
|
|||||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
} catch {
|
} catch {
|
||||||
this.sseClients.delete(reply);
|
this.sseClients.delete(reply);
|
||||||
|
this.remoteSseClients.delete(reply);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1976,6 +1987,7 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.sseClients.delete(reply);
|
this.sseClients.delete(reply);
|
||||||
|
this.remoteSseClients.delete(reply);
|
||||||
this.backpressuredClients.delete(reply);
|
this.backpressuredClients.delete(reply);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2316,6 +2328,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// Remove dead clients
|
// Remove dead clients
|
||||||
for (const client of deadClients) {
|
for (const client of deadClients) {
|
||||||
this.sseClients.delete(client);
|
this.sseClients.delete(client);
|
||||||
|
this.remoteSseClients.delete(client);
|
||||||
this.backpressuredClients.delete(client);
|
this.backpressuredClients.delete(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2704,6 +2717,7 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.sseClients.clear();
|
this.sseClients.clear();
|
||||||
|
this.remoteSseClients.clear();
|
||||||
this.backpressuredClients.clear();
|
this.backpressuredClients.clear();
|
||||||
|
|
||||||
// Clear per-session batch timers
|
// Clear per-session batch timers
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
|||||||
batchTerminalData: vi.fn(),
|
batchTerminalData: vi.fn(),
|
||||||
broadcastSessionStateDebounced: vi.fn(),
|
broadcastSessionStateDebounced: vi.fn(),
|
||||||
batchTaskUpdate: vi.fn(),
|
batchTaskUpdate: vi.fn(),
|
||||||
|
getSseClientCount: vi.fn(() => 0),
|
||||||
|
|
||||||
// -- RespawnPort --
|
// -- RespawnPort --
|
||||||
respawnControllers: new Map(),
|
respawnControllers: new Map(),
|
||||||
|
|||||||
Reference in New Issue
Block a user