From 67d0b0b538b967a63dc34510ce525dee57b66b70 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 8 Mar 2026 16:06:15 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- src/web/ports/event-port.ts | 1 + src/web/public/app.js | 226 +++++++++++++++++++++++++++- src/web/public/index.html | 3 + src/web/public/styles.css | 243 +++++++++++++++++++++++++++++++ src/web/routes/respawn-routes.ts | 1 + src/web/routes/system-routes.ts | 17 +++ src/web/server.ts | 14 ++ test/mocks/mock-route-context.ts | 1 + 9 files changed, 505 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a399f2ca..59a0256e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/web/ports/event-port.ts b/src/web/ports/event-port.ts index 142bd686..1298dfb7 100644 --- a/src/web/ports/event-port.ts +++ b/src/web/ports/event-port.ts @@ -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; } diff --git a/src/web/public/app.js b/src/web/public/app.js index 1d03a912..f075f34b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -351,6 +351,9 @@ class CodemanApp { this.imagePopups = new Map(); // Map this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE; + // Tunnel indicator state + this._tunnelUrl = null; + // Tab alert states: Map 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 = ` +
+

+ + Cloudflare Tunnel + Loading... +

+
+
+
Loading...
+
+ `; + 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 = '
Failed to load tunnel info
'; + } + } + + _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 += ` +
+
URL
+
${escapeHtml(info.url)}
+
`; + } + + // Clients section + html += ` +
+
Connections
+
+ Remote Clients + ${info.sseClients} +
`; + + if (info.authEnabled) { + html += ` +
+ Auth Sessions + ${info.authSessions.length} +
`; + } + html += '
'; + + // Auth sessions detail + if (info.authEnabled && info.authSessions.length > 0) { + html += '
Authenticated Devices
'; + 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 += ` +
+ + ${escapeHtml(browser)} · ${escapeHtml(s.ip)} · ${ago} + ${s.method} +
`; + } + html += '
'; + } + + // Actions + html += '
'; + if (info.running) { + html += ` + + `; + } else { + html += ``; + } + html += '
'; + + // Revoke all sessions button + if (info.authEnabled && info.authSessions.length > 0) { + html += ` +
+ +
`; + } + + 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'); diff --git a/src/web/public/index.html b/src/web/public/index.html index 0930b854..59d08694 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -59,6 +59,9 @@
+