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 @@