Implementing WebSocked Support for immediately ban-messages

This commit is contained in:
2025-12-15 20:12:41 +01:00
parent 5163e4f1f4
commit 3ad4821cb7
15 changed files with 930 additions and 139 deletions

View File

@@ -18,6 +18,13 @@ function showLoading(show) {
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container || !message) return;
// Handle ban event objects
if (typeof message === 'object' && message.type === 'ban_event') {
showBanEventToast(message.data || message);
return;
}
var toast = document.createElement('div');
var variant = type || 'info';
toast.className = 'toast toast-' + variant;
@@ -34,6 +41,60 @@ function showToast(message, type) {
}, 5000);
}
// Show toast for ban event
function showBanEventToast(event) {
var container = document.getElementById('toast-container');
if (!container || !event) return;
var toast = document.createElement('div');
toast.className = 'toast toast-ban-event';
var ip = event.ip || 'Unknown IP';
var jail = event.jail || 'Unknown Jail';
var server = event.serverName || event.serverId || 'Unknown Server';
var country = event.country || 'UNKNOWN';
toast.innerHTML = ''
+ '<div class="flex items-start gap-3">'
+ ' <div class="flex-shrink-0 mt-1">'
+ ' <i class="fas fa-shield-alt text-red-500"></i>'
+ ' </div>'
+ ' <div class="flex-1 min-w-0">'
+ ' <div class="font-semibold text-sm">New Block Detected</div>'
+ ' <div class="text-sm mt-1">'
+ ' <span class="font-mono font-semibold">' + escapeHtml(ip) + '</span>'
+ ' <span class="text-gray-500"> banned in </span>'
+ ' <span class="font-semibold">' + escapeHtml(jail) + '</span>'
+ ' </div>'
+ ' <div class="text-xs text-gray-400 mt-1">'
+ ' ' + escapeHtml(server) + ' • ' + escapeHtml(country)
+ ' </div>'
+ ' </div>'
+ '</div>';
// Add click handler to scroll to ban events table
toast.addEventListener('click', function() {
var logSection = document.getElementById('logOverviewSection');
if (logSection) {
logSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
toast.style.cursor = 'pointer';
container.appendChild(toast);
requestAnimationFrame(function() {
toast.classList.add('show');
});
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() {
toast.remove();
}, 300);
}, 5000);
}
// Escape HTML to prevent XSS
function escapeHtml(value) {
if (value === undefined || value === null) return '';

View File

@@ -74,6 +74,10 @@ function fetchBanEventsData() {
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanEvents = data && data.events ? data.events : [];
// Track the last event ID to prevent duplicates from WebSocket
if (latestBanEvents.length > 0 && wsManager) {
wsManager.lastBanEventId = latestBanEvents[0].id;
}
})
.catch(function(err) {
console.error('Error fetching ban events:', err);
@@ -81,6 +85,72 @@ function fetchBanEventsData() {
});
}
// Add new ban event from WebSocket
function addBanEventFromWebSocket(event) {
// Check if event already exists (prevent duplicates)
// Only check by ID if both events have IDs
var exists = false;
if (event.id) {
exists = latestBanEvents.some(function(e) {
return e.id === event.id;
});
} else {
// If no ID, check by IP, jail, and occurredAt timestamp
exists = latestBanEvents.some(function(e) {
return e.ip === event.ip &&
e.jail === event.jail &&
e.occurredAt === event.occurredAt;
});
}
if (!exists) {
console.log('Adding new ban event from WebSocket:', event);
// Prepend to the beginning of the array
latestBanEvents.unshift(event);
// Keep only the last 200 events
if (latestBanEvents.length > 200) {
latestBanEvents = latestBanEvents.slice(0, 200);
}
// Show toast notification first
if (typeof showBanEventToast === 'function') {
showBanEventToast(event);
}
// Refresh dashboard data (summary, stats, insights) and re-render
refreshDashboardData();
} else {
console.log('Skipping duplicate ban event:', event);
}
}
// Refresh dashboard data when new ban event arrives via WebSocket
function refreshDashboardData() {
// Refresh ban statistics and insights in the background
// Also refresh summary if we have a server selected
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
var summaryPromise;
if (serversCache.length && enabledServers.length && currentServerId) {
summaryPromise = fetchSummaryData();
} else {
summaryPromise = Promise.resolve();
}
Promise.all([
summaryPromise,
fetchBanStatisticsData(),
fetchBanInsightsData()
]).then(function() {
// Re-render the dashboard to show updated stats
renderDashboard();
}).catch(function(err) {
console.error('Error refreshing dashboard data:', err);
// Still re-render even if refresh fails
renderDashboard();
});
}
function fetchBanInsightsData() {
var sevenDaysAgo = new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString();
var sinceQuery = '?since=' + encodeURIComponent(sevenDaysAgo);

102
pkg/web/static/js/header.js Normal file
View File

@@ -0,0 +1,102 @@
// Header components: Clock and Backend Status Indicator
"use strict";
var clockInterval = null;
var statusUpdateCallback = null;
// Initialize clock
function initClock() {
function updateClock() {
var now = new Date();
var hours = String(now.getHours()).padStart(2, '0');
var minutes = String(now.getMinutes()).padStart(2, '0');
var seconds = String(now.getSeconds()).padStart(2, '0');
var timeString = hours + ':' + minutes + ':' + seconds;
var clockElement = document.getElementById('clockTime');
if (clockElement) {
clockElement.textContent = timeString;
}
}
// Update immediately
updateClock();
// Update every second
if (clockInterval) {
clearInterval(clockInterval);
}
clockInterval = setInterval(updateClock, 1000);
}
// Update status indicator
function updateStatusIndicator(state, text) {
var statusDot = document.getElementById('statusDot');
var statusText = document.getElementById('statusText');
if (!statusDot || !statusText) {
return;
}
// Remove all color classes
statusDot.classList.remove('bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-gray-400');
// Set color and text based on state
switch (state) {
case 'connected':
statusDot.classList.add('bg-green-500');
statusText.textContent = text || 'Connected';
break;
case 'connecting':
case 'reconnecting':
statusDot.classList.add('bg-yellow-500');
statusText.textContent = text || 'Connecting...';
break;
case 'disconnected':
case 'error':
statusDot.classList.add('bg-red-500');
statusText.textContent = text || 'Disconnected';
break;
default:
statusDot.classList.add('bg-gray-400');
statusText.textContent = text || 'Unknown';
}
}
// Initialize status indicator
function initStatusIndicator() {
// Set initial state
updateStatusIndicator('connecting', 'Connecting...');
// Register callback with WebSocket manager when available
if (typeof wsManager !== 'undefined' && wsManager) {
wsManager.onStatusChange(function(state, text) {
updateStatusIndicator(state, text);
});
} else {
// Wait for WebSocket manager to be available
var checkInterval = setInterval(function() {
if (typeof wsManager !== 'undefined' && wsManager) {
wsManager.onStatusChange(function(state, text) {
updateStatusIndicator(state, text);
});
clearInterval(checkInterval);
}
}, 100);
}
}
// Initialize all header components
function initHeader() {
initClock();
initStatusIndicator();
}
// Cleanup on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', function() {
if (clockInterval) {
clearInterval(clockInterval);
}
});
}

View File

@@ -5,6 +5,38 @@ window.addEventListener('DOMContentLoaded', function() {
showLoading(true);
displayExternalIP();
// Initialize header components (clock and status indicator)
if (typeof initHeader === 'function') {
initHeader();
}
// Initialize WebSocket connection and register ban event handler
function registerBanEventHandler() {
if (typeof wsManager !== 'undefined' && wsManager) {
wsManager.onBanEvent(function(event) {
if (typeof addBanEventFromWebSocket === 'function') {
addBanEventFromWebSocket(event);
}
});
return true;
}
return false;
}
if (!registerBanEventHandler()) {
// Wait for WebSocket manager to be available
var wsCheckInterval = setInterval(function() {
if (registerBanEventHandler()) {
clearInterval(wsCheckInterval);
}
}, 100);
// Stop checking after 5 seconds
setTimeout(function() {
clearInterval(wsCheckInterval);
}, 5000);
}
// Check LOTR mode on page load to apply immediately
fetch('/api/settings')
.then(res => res.json())
@@ -114,4 +146,3 @@ window.addEventListener('DOMContentLoaded', function() {
}
});
});

View File

@@ -0,0 +1,205 @@
// WebSocket Manager for Fail2ban UI
// Handles real-time communication with the backend
"use strict";
class WebSocketManager {
constructor() {
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = Infinity;
this.reconnectDelay = 1000; // Start with 1 second
this.maxReconnectDelay = 30000; // Max 30 seconds
this.isConnecting = false;
this.isConnected = false;
this.lastBanEventId = null;
this.statusCallbacks = [];
this.banEventCallbacks = [];
// Get WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
this.wsUrl = `${protocol}//${host}/api/ws`;
this.connect();
}
connect() {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
return;
}
this.isConnecting = true;
this.updateStatus('connecting', 'Connecting...');
try {
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = () => {
this.isConnecting = false;
this.isConnected = true;
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
this.updateStatus('connected', 'Connected');
console.log('WebSocket connected');
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (err) {
console.error('Error parsing WebSocket message:', err);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.updateStatus('error', 'Connection error');
};
this.ws.onclose = () => {
this.isConnecting = false;
this.isConnected = false;
this.updateStatus('disconnected', 'Disconnected');
console.log('WebSocket disconnected');
// Attempt to reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
}
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
this.isConnecting = false;
this.updateStatus('error', 'Connection failed');
this.scheduleReconnect();
}
}
scheduleReconnect() {
this.reconnectAttempts++;
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
this.updateStatus('reconnecting', 'Reconnecting...');
setTimeout(() => {
this.connect();
}, delay);
}
handleMessage(message) {
switch (message.type) {
case 'ban_event':
this.handleBanEvent(message.data);
break;
case 'heartbeat':
this.handleHeartbeat(message);
break;
default:
console.log('Unknown message type:', message.type);
}
}
handleBanEvent(eventData) {
// Check if we've already processed this event (prevent duplicates)
// Only check if event has an ID and we have a lastBanEventId
if (eventData.id && this.lastBanEventId !== null && eventData.id <= this.lastBanEventId) {
console.log('Skipping duplicate ban event:', eventData.id);
return;
}
// Update lastBanEventId if event has an ID
if (eventData.id) {
if (this.lastBanEventId === null || eventData.id > this.lastBanEventId) {
this.lastBanEventId = eventData.id;
}
}
console.log('Processing ban event:', eventData);
// Notify all registered callbacks
this.banEventCallbacks.forEach(callback => {
try {
callback(eventData);
} catch (err) {
console.error('Error in ban event callback:', err);
}
});
}
handleHeartbeat(message) {
// Update status to show backend is healthy
if (this.isConnected) {
this.updateStatus('connected', 'Connected');
}
}
updateStatus(state, text) {
this.statusCallbacks.forEach(callback => {
try {
callback(state, text);
} catch (err) {
console.error('Error in status callback:', err);
}
});
}
onStatusChange(callback) {
this.statusCallbacks.push(callback);
}
onBanEvent(callback) {
this.banEventCallbacks.push(callback);
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isConnected = false;
this.isConnecting = false;
}
getConnectionState() {
if (!this.ws) {
return 'disconnected';
}
switch (this.ws.readyState) {
case WebSocket.CONNECTING:
return 'connecting';
case WebSocket.OPEN:
return 'connected';
case WebSocket.CLOSING:
return 'disconnecting';
case WebSocket.CLOSED:
return 'disconnected';
default:
return 'unknown';
}
}
isHealthy() {
return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN;
}
}
// Create global instance - initialize immediately
var wsManager = null;
// Initialize WebSocket manager
if (typeof window !== 'undefined') {
// Initialize immediately if DOM is already loaded, otherwise wait
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
if (!wsManager) {
wsManager = new WebSocketManager();
}
});
} else {
wsManager = new WebSocketManager();
}
}