mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +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` | |
|
||||
| **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) | |
|
||||
| **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.
|
||||
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface EventPort {
|
||||
batchTerminalData(sessionId: string, data: string): void;
|
||||
broadcastSessionStateDebounced(sessionId: string): 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.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||
|
||||
// Tunnel indicator state
|
||||
this._tunnelUrl = null;
|
||||
|
||||
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
||||
this.tabAlerts = new Map();
|
||||
|
||||
@@ -519,6 +522,8 @@ class CodemanApp {
|
||||
});
|
||||
// Register service worker for push notifications
|
||||
this.registerServiceWorker();
|
||||
// Fetch tunnel status for header indicator (desktop only)
|
||||
this.loadTunnelStatus();
|
||||
// Share a single settings fetch between both consumers
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
@@ -2617,8 +2622,10 @@ class CodemanApp {
|
||||
// Tunnel
|
||||
_onTunnelStarted(data) {
|
||||
console.log('[Tunnel] Started:', data.url);
|
||||
this._tunnelUrl = data.url;
|
||||
this._dismissTunnelConnecting();
|
||||
this._updateTunnelUrlDisplay(data.url);
|
||||
this._updateTunnelIndicator(true);
|
||||
const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible');
|
||||
if (welcomeVisible) {
|
||||
// On welcome screen: QR appears inline, expanded first
|
||||
@@ -2634,9 +2641,12 @@ class CodemanApp {
|
||||
|
||||
_onTunnelStopped() {
|
||||
console.log('[Tunnel] Stopped');
|
||||
this._tunnelUrl = null;
|
||||
this._dismissTunnelConnecting();
|
||||
this._updateTunnelUrlDisplay(null);
|
||||
this._updateWelcomeTunnelBtn(false);
|
||||
this._updateTunnelIndicator(false);
|
||||
this.closeTunnelPanel();
|
||||
this.closeTunnelQR();
|
||||
}
|
||||
|
||||
@@ -6471,11 +6481,15 @@ class CodemanApp {
|
||||
const res = await fetch('/api/tunnel/status');
|
||||
const status = await res.json();
|
||||
const active = status.running && status.url;
|
||||
this._updateTunnelUrlDisplay(active ? status.url : null);
|
||||
this._updateWelcomeTunnelBtn(!!active, active ? status.url : null);
|
||||
this._tunnelUrl = active ? status.url : null;
|
||||
this._updateTunnelUrlDisplay(this._tunnelUrl);
|
||||
this._updateWelcomeTunnelBtn(!!active, this._tunnelUrl);
|
||||
this._updateTunnelIndicator(!!active);
|
||||
} catch {
|
||||
this._tunnelUrl = null;
|
||||
this._updateTunnelUrlDisplay(null);
|
||||
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() {
|
||||
const input = document.getElementById('voiceDeepgramKey');
|
||||
const btn = document.getElementById('voiceKeyToggleBtn');
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
|
||||
@@ -444,6 +444,249 @@ body {
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -28,6 +28,7 @@ const noopEventPort: EventPort = {
|
||||
batchTerminalData: () => {},
|
||||
broadcastSessionStateDebounced: () => {},
|
||||
batchTaskUpdate: () => {},
|
||||
getSseClientCount: () => 0,
|
||||
};
|
||||
|
||||
export function registerRespawnRoutes(
|
||||
|
||||
@@ -104,6 +104,23 @@ export function registerSystemRoutes(
|
||||
|
||||
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) => {
|
||||
const url = ctx.tunnelManager.getUrl();
|
||||
if (!url) {
|
||||
|
||||
@@ -217,6 +217,8 @@ export class WebServer extends EventEmitter {
|
||||
* or `null` meaning "receive all events" (backwards-compatible default).
|
||||
*/
|
||||
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 */
|
||||
private backpressuredClients: Set<FastifyReply> = new Set();
|
||||
private store = getStore();
|
||||
@@ -505,6 +507,7 @@ export class WebServer extends EventEmitter {
|
||||
batchTerminalData: this.batchTerminalData.bind(this),
|
||||
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
||||
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
||||
getSseClientCount: () => this.remoteSseClients.size,
|
||||
// RespawnPort
|
||||
respawnControllers: this.respawnControllers,
|
||||
respawnTimers: this.respawnTimers,
|
||||
@@ -620,6 +623,12 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
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
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
@@ -636,6 +645,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
req.raw.on('close', () => {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.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`);
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1976,6 +1987,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
}
|
||||
}
|
||||
@@ -2316,6 +2328,7 @@ export class WebServer extends EventEmitter {
|
||||
// Remove dead clients
|
||||
for (const client of deadClients) {
|
||||
this.sseClients.delete(client);
|
||||
this.remoteSseClients.delete(client);
|
||||
this.backpressuredClients.delete(client);
|
||||
}
|
||||
|
||||
@@ -2704,6 +2717,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
this.sseClients.clear();
|
||||
this.remoteSseClients.clear();
|
||||
this.backpressuredClients.clear();
|
||||
|
||||
// Clear per-session batch timers
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
batchTerminalData: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
getSseClientCount: vi.fn(() => 0),
|
||||
|
||||
// -- RespawnPort --
|
||||
respawnControllers: new Map(),
|
||||
|
||||
Reference in New Issue
Block a user