diff --git a/pkg/web/static/fail2ban-ui.css b/pkg/web/static/fail2ban-ui.css index ae33264..721ff2b 100644 --- a/pkg/web/static/fail2ban-ui.css +++ b/pkg/web/static/fail2ban-ui.css @@ -430,4 +430,46 @@ button.bg-red-500:hover, button.bg-red-600:hover { .text-green-800 { --tw-text-opacity: 1; color: rgb(22 101 52 / var(--tw-text-opacity, 1)); +} + +/* WebSocket Tooltip Styling */ +#wsTooltip { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.5; +} + +#wsTooltip .text-green-400 { + color: rgb(74 222 128); +} + +#wsTooltip .text-blue-400 { + color: rgb(96 165 250); +} + +#wsTooltip .text-yellow-400 { + color: rgb(250 204 21); +} + +#wsTooltip .text-orange-400 { + color: rgb(251 146 60); +} + +#wsTooltip .text-gray-400 { + color: rgb(156 163 175); +} + +#wsTooltip .text-gray-500 { + color: rgb(107 114 128); +} + +#wsTooltip .border-gray-700 { + border-color: rgb(55 65 81); +} + +#wsTooltip .border-b { + border-bottom-width: 1px; +} + +#wsTooltip .border-t { + border-top-width: 1px; } \ No newline at end of file diff --git a/pkg/web/static/js/header.js b/pkg/web/static/js/header.js index 0b5b9f7..ccfb82f 100644 --- a/pkg/web/static/js/header.js +++ b/pkg/web/static/js/header.js @@ -86,10 +86,138 @@ function initStatusIndicator() { } } +// Create and manage WebSocket tooltip +function createWebSocketTooltip() { + // Create tooltip element + const tooltip = document.createElement('div'); + tooltip.id = 'wsTooltip'; + tooltip.className = 'fixed z-50 px-3 py-2 bg-gray-900 text-white text-xs rounded shadow-lg pointer-events-none opacity-0 transition-opacity duration-200'; + tooltip.style.display = 'none'; + tooltip.style.minWidth = '200px'; + document.body.appendChild(tooltip); + + const statusEl = document.getElementById('backendStatus'); + if (!statusEl) { + return; + } + + let tooltipUpdateInterval = null; + + function updateTooltipContent() { + if (!wsManager || !wsManager.isConnected) { + return; + } + + const info = wsManager.getConnectionInfo(); + if (!info) { + return; + } + + tooltip.innerHTML = ` +
WebSocket Connection
+
+
+ Duration: + ${info.duration} +
+
+ Last Heartbeat: + ${info.lastHeartbeat} +
+
+ Messages: + ${info.messages} +
+
+ Reconnects: + ${info.reconnects} +
+
+
${info.protocol}
+
${info.url}
+
+
+ `; + } + + function showTooltip(e) { + if (!wsManager || !wsManager.isConnected) { + return; + } + + updateTooltipContent(); + const rect = statusEl.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + + // Position tooltip below the status element, centered + let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); + let top = rect.bottom + 8; + + // Adjust if tooltip would go off screen + if (left < 8) left = 8; + if (left + tooltipRect.width > window.innerWidth - 8) { + left = window.innerWidth - tooltipRect.width - 8; + } + if (top + tooltipRect.height > window.innerHeight - 8) { + top = rect.top - tooltipRect.height - 8; + } + + tooltip.style.left = left + 'px'; + tooltip.style.top = top + 'px'; + tooltip.style.display = 'block'; + setTimeout(() => { + tooltip.style.opacity = '1'; + }, 10); + + // Update tooltip content every second while visible + if (tooltipUpdateInterval) { + clearInterval(tooltipUpdateInterval); + } + tooltipUpdateInterval = setInterval(updateTooltipContent, 1000); + } + + function hideTooltip() { + tooltip.style.opacity = '0'; + setTimeout(() => { + tooltip.style.display = 'none'; + }, 200); + + if (tooltipUpdateInterval) { + clearInterval(tooltipUpdateInterval); + tooltipUpdateInterval = null; + } + } + + statusEl.addEventListener('mouseenter', showTooltip); + statusEl.addEventListener('mouseleave', hideTooltip); + + // Also hide tooltip when status changes to disconnected + if (typeof wsManager !== 'undefined' && wsManager) { + wsManager.onStatusChange(function(state, text) { + if (state !== 'connected') { + hideTooltip(); + } + }); + } else { + // Wait for WebSocket manager to be available + var checkInterval = setInterval(function() { + if (typeof wsManager !== 'undefined' && wsManager) { + wsManager.onStatusChange(function(state, text) { + if (state !== 'connected') { + hideTooltip(); + } + }); + clearInterval(checkInterval); + } + }, 100); + } +} + // Initialize all header components function initHeader() { initClock(); initStatusIndicator(); + createWebSocketTooltip(); } // Cleanup on page unload diff --git a/pkg/web/static/js/websocket.js b/pkg/web/static/js/websocket.js index 4463785..07cff34 100644 --- a/pkg/web/static/js/websocket.js +++ b/pkg/web/static/js/websocket.js @@ -16,6 +16,13 @@ class WebSocketManager { this.statusCallbacks = []; this.banEventCallbacks = []; + // Connection metrics for tooltip + this.connectedAt = null; + this.lastHeartbeatAt = null; + this.messageCount = 0; + this.totalReconnects = 0; + this.initialConnection = true; + // Get WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; @@ -38,6 +45,11 @@ class WebSocketManager { this.ws.onopen = () => { this.isConnecting = false; this.isConnected = true; + this.connectedAt = new Date(); + if (!this.initialConnection) { + this.totalReconnects++; + } + this.initialConnection = false; this.reconnectAttempts = 0; this.reconnectDelay = 1000; this.updateStatus('connected', 'Connected'); @@ -47,6 +59,7 @@ class WebSocketManager { this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); + this.messageCount++; this.handleMessage(message); } catch (err) { console.error('Error parsing WebSocket message:', err); @@ -133,6 +146,7 @@ class WebSocketManager { handleHeartbeat(message) { // Update status to show backend is healthy + this.lastHeartbeatAt = new Date(); if (this.isConnected) { this.updateStatus('connected', 'Connected'); } @@ -187,6 +201,46 @@ class WebSocketManager { isHealthy() { return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN; } + + formatDuration(seconds) { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}m ${secs}s`; + } + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return `${hours}h ${mins}m`; + } + + getConnectionInfo() { + if (!this.isConnected || !this.connectedAt) { + return null; + } + + const now = new Date(); + const duration = Math.floor((now - this.connectedAt) / 1000); + const durationStr = this.formatDuration(duration); + + const lastHeartbeat = this.lastHeartbeatAt + ? Math.floor((now - this.lastHeartbeatAt) / 1000) + : null; + const heartbeatStr = lastHeartbeat !== null + ? (lastHeartbeat < 60 ? `${lastHeartbeat}s ago` : `${Math.floor(lastHeartbeat / 60)}m ago`) + : 'Never'; + + const protocol = this.wsUrl.startsWith('wss:') ? 'WSS (Secure)' : 'WS'; + + return { + duration: durationStr, + lastHeartbeat: heartbeatStr, + url: this.wsUrl, + messages: this.messageCount, + reconnects: this.totalReconnects, + protocol: protocol + }; + } } // Create global instance - initialize immediately diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 0e29661..d01a6c5 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -73,7 +73,7 @@
- Fail2ban UI + Fail2ban UI