diff --git a/internal/locales/de.json b/internal/locales/de.json index e637a55..0e54f75 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -22,17 +22,22 @@ "dashboard.cards.total_banned": "Gesamt gesperrte IPs", "dashboard.cards.new_last_hour": "Neue in der letzten Stunde", "dashboard.cards.total_logged": "Gespeicherte Sperr-Ereignisse", + "dashboard.cards.recurring_week": "Wiederkehrende IPs (7 Tage)", + "dashboard.cards.recurring_hint": "Behalte wiederholte Angreifer der letzten 7 Tage im Auge.", "dashboard.table.jail_name": "Jail-Name", "dashboard.table.total_banned": "Insgesamt gesperrt", "dashboard.table.new_last_hour": "Neu in letzter Stunde", "dashboard.table.banned_ips": "Gesperrte IPs (Entsperren)", "dashboard.no_jails": "Keine Jails gefunden.", + "dashboard.overview_detail": "Listen ein- oder ausklappen, um betroffene Dienste schneller zu sehen.", "dashboard.table.time": "Zeit", "dashboard.table.jail": "Jail", "dashboard.table.ip": "IP", "dashboard.table.log_line": "Logzeile", "dashboard.no_banned_ips": "Keine gesperrten IPs", "dashboard.unban": "Entsperren", + "dashboard.banned.show_more": "Mehr anzeigen", + "dashboard.banned.show_less": "Weniger anzeigen", "logs.overview.title": "Interne Log-Übersicht", "logs.overview.subtitle": "Von Fail2ban-UI gespeicherte Ereignisse über alle Connectoren.", "logs.overview.refresh": "Daten aktualisieren", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index f50a0c1..ac10798 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -22,17 +22,22 @@ "dashboard.cards.total_banned": "Total g'sperrti IPs", "dashboard.cards.new_last_hour": "Neu i dr letschte Stund", "dashboard.cards.total_logged": "Gspeichereti Sperr-Ereigniss", + "dashboard.cards.recurring_week": "Widerkehrendi IPs (7 Täg)", + "dashboard.cards.recurring_hint": "Beobachte wiederholti Angreifer us de letschte 7 Täg.", "dashboard.table.jail_name": "Jail-Name", "dashboard.table.total_banned": "Insgsamt g'sperrt", "dashboard.table.new_last_hour": "Neu in dr letschte Stund", "dashboard.table.banned_ips": "G'sperrti IPs (Entsperre)", "dashboard.no_jails": "Kei Jails gfunde.", + "dashboard.overview_detail": "Listä zämme- oder usklappe, zum schnäll betroffene Dinst erkenne.", "dashboard.table.time": "Zyt", "dashboard.table.jail": "Jail", "dashboard.table.ip": "IP", "dashboard.table.log_line": "Log-Zile", "dashboard.no_banned_ips": "Kei g'sperrti IPs", "dashboard.unban": "Entsperre", + "dashboard.banned.show_more": "Meh azeige", + "dashboard.banned.show_less": "Weniger azeige", "logs.overview.title": "Interni Log-Übersicht", "logs.overview.subtitle": "Vo Fail2ban-UI gspeichereti Ereigniss über alli Connectorä.", "logs.overview.refresh": "Date aktualisiere", diff --git a/internal/locales/en.json b/internal/locales/en.json index d5d3cd5..f2ae3c1 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -22,17 +22,22 @@ "dashboard.cards.total_banned": "Total Banned IPs", "dashboard.cards.new_last_hour": "New Last Hour", "dashboard.cards.total_logged": "Stored Ban Events", + "dashboard.cards.recurring_week": "Recurring IPs (7 days)", + "dashboard.cards.recurring_hint": "Watch repeated offenders detected in the last seven days.", "dashboard.table.jail_name": "Jail Name", "dashboard.table.total_banned": "Total Banned", "dashboard.table.new_last_hour": "New Last Hour", "dashboard.table.banned_ips": "Banned IPs (Unban)", "dashboard.no_jails": "No jails found.", + "dashboard.overview_detail": "Collapse or expand long lists to quickly focus on impacted services.", "dashboard.table.time": "Time", "dashboard.table.jail": "Jail", "dashboard.table.ip": "IP", "dashboard.table.log_line": "Log Line", "dashboard.no_banned_ips": "No banned IPs", "dashboard.unban": "Unban", + "dashboard.banned.show_more": "Show more", + "dashboard.banned.show_less": "Hide extra", "logs.overview.title": "Internal Log Overview", "logs.overview.subtitle": "Events stored by Fail2ban-UI across all connectors.", "logs.overview.refresh": "Refresh data", diff --git a/internal/locales/es.json b/internal/locales/es.json index bda6d3d..2939c94 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -22,17 +22,22 @@ "dashboard.cards.total_banned": "IPs bloqueadas totales", "dashboard.cards.new_last_hour": "Nuevas en la última hora", "dashboard.cards.total_logged": "Eventos de bloqueo almacenados", + "dashboard.cards.recurring_week": "IPs recurrentes (7 días)", + "dashboard.cards.recurring_hint": "Vigila a los infractores repetidos de los últimos 7 días.", "dashboard.table.jail_name": "Nombre del Jail", "dashboard.table.total_banned": "Total bloqueadas", "dashboard.table.new_last_hour": "Nuevas en la última hora", "dashboard.table.banned_ips": "IPs bloqueadas (Desbloquear)", "dashboard.no_jails": "No se encontraron jails.", + "dashboard.overview_detail": "Colapsa o expande las listas largas para centrarte en los servicios afectados.", "dashboard.table.time": "Hora", "dashboard.table.jail": "Jail", "dashboard.table.ip": "IP", "dashboard.table.log_line": "Línea de log", "dashboard.no_banned_ips": "No hay IP bloqueadas", "dashboard.unban": "Desbloquear", + "dashboard.banned.show_more": "Mostrar más", + "dashboard.banned.show_less": "Mostrar menos", "logs.overview.title": "Resumen interno de registros", "logs.overview.subtitle": "Eventos almacenados por Fail2ban-UI a través de todos los conectores.", "logs.overview.refresh": "Actualizar datos", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 5c542f3..c0dd7f7 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -22,17 +22,22 @@ "dashboard.cards.total_banned": "Total d'IPs bloquées", "dashboard.cards.new_last_hour": "Nouvelles dans la dernière heure", "dashboard.cards.total_logged": "Événements de blocage enregistrés", + "dashboard.cards.recurring_week": "IPs récurrentes (7 jours)", + "dashboard.cards.recurring_hint": "Surveillez les récidivistes observés durant les 7 derniers jours.", "dashboard.table.jail_name": "Nom du Jail", "dashboard.table.total_banned": "Total bloqués", "dashboard.table.new_last_hour": "Nouveaux dans la dernière heure", "dashboard.table.banned_ips": "IPs bloquées (Débloquer)", "dashboard.no_jails": "Aucun jail trouvé.", + "dashboard.overview_detail": "Réduisez ou développez les longues listes pour vous concentrer sur les services impactés.", "dashboard.table.time": "Heure", "dashboard.table.jail": "Jail", "dashboard.table.ip": "IP", "dashboard.table.log_line": "Ligne de log", "dashboard.no_banned_ips": "Aucune IP bloquée", "dashboard.unban": "Débloquer", + "dashboard.banned.show_more": "Afficher plus", + "dashboard.banned.show_less": "Afficher moins", "logs.overview.title": "Vue d'ensemble interne des journaux", "logs.overview.subtitle": "Événements enregistrés par Fail2ban-UI sur l'ensemble des connecteurs.", "logs.overview.refresh": "Actualiser les données", diff --git a/internal/locales/it.json b/internal/locales/it.json index 23b2c94..9381f2a 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -22,17 +22,22 @@ "dashboard.cards.total_banned": "IP bloccate totali", "dashboard.cards.new_last_hour": "Nuove nell'ultima ora", "dashboard.cards.total_logged": "Eventi di blocco memorizzati", + "dashboard.cards.recurring_week": "IP ricorrenti (7 giorni)", + "dashboard.cards.recurring_hint": "Tieni d'occhio gli indirizzi ricorrenti degli ultimi 7 giorni.", "dashboard.table.jail_name": "Nome del Jail", "dashboard.table.total_banned": "Totale bloccate", "dashboard.table.new_last_hour": "Nuove nell'ultima ora", "dashboard.table.banned_ips": "IP bloccate (Sblocca)", "dashboard.no_jails": "Nessun jail trovato.", + "dashboard.overview_detail": "Comprimi o espandi gli elenchi lunghi per concentrarti sui servizi interessati.", "dashboard.table.time": "Ora", "dashboard.table.jail": "Jail", "dashboard.table.ip": "IP", "dashboard.table.log_line": "Riga di log", "dashboard.no_banned_ips": "Nessuna IP bloccata", "dashboard.unban": "Sblocca", + "dashboard.banned.show_more": "Mostra di più", + "dashboard.banned.show_less": "Mostra meno", "logs.overview.title": "Panoramica interna dei log", "logs.overview.subtitle": "Eventi memorizzati da Fail2ban-UI su tutti i connettori.", "logs.overview.refresh": "Aggiorna dati", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 0dfd002..d8209f7 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -537,8 +537,8 @@ WHERE 1=1` return result, rows.Err() } -// CountBanEvents returns total number of ban events optionally filtered by time. -func CountBanEvents(ctx context.Context, since time.Time) (int64, error) { +// CountBanEvents returns total number of ban events optionally filtered by time and server. +func CountBanEvents(ctx context.Context, since time.Time, serverID string) (int64, error) { if db == nil { return 0, errors.New("storage not initialised") } @@ -549,6 +549,11 @@ FROM ban_events WHERE 1=1` args := []any{} + if serverID != "" { + query += " AND server_id = ?" + args = append(args, serverID) + } + if !since.IsZero() { query += " AND occurred_at >= ?" args = append(args, since.UTC()) @@ -561,8 +566,8 @@ WHERE 1=1` return total, nil } -// CountBanEventsByCountry returns aggregation per country code. -func CountBanEventsByCountry(ctx context.Context, since time.Time) (map[string]int64, error) { +// CountBanEventsByCountry returns aggregation per country code, optionally filtered by server. +func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) { if db == nil { return nil, errors.New("storage not initialised") } @@ -573,6 +578,11 @@ FROM ban_events WHERE 1=1` args := []any{} + if serverID != "" { + query += " AND server_id = ?" + args = append(args, serverID) + } + if !since.IsZero() { query += " AND occurred_at >= ?" args = append(args, since.UTC()) @@ -599,8 +609,8 @@ WHERE 1=1` return result, rows.Err() } -// ListRecurringIPStats returns IPs that have been banned at least minCount times. -func ListRecurringIPStats(ctx context.Context, since time.Time, minCount, limit int) ([]RecurringIPStat, error) { +// ListRecurringIPStats returns IPs that have been banned at least minCount times, optionally filtered by server. +func ListRecurringIPStats(ctx context.Context, since time.Time, minCount, limit int, serverID string) ([]RecurringIPStat, error) { if db == nil { return nil, errors.New("storage not initialised") } @@ -618,6 +628,11 @@ FROM ban_events WHERE ip != ''` args := []any{} + if serverID != "" { + query += " AND server_id = ?" + args = append(args, serverID) + } + if !since.IsZero() { query += " AND occurred_at >= ?" args = append(args, since.UTC()) diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 5cec32b..a15290e 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -248,6 +248,7 @@ func BanInsightsHandler(c *gin.Context) { since = parsed } } + serverID := c.Query("serverId") minCount := 3 if minCountStr := c.DefaultQuery("minCount", "3"); minCountStr != "" { @@ -265,19 +266,19 @@ func BanInsightsHandler(c *gin.Context) { ctx := c.Request.Context() - countriesMap, err := storage.CountBanEventsByCountry(ctx, since) + countriesMap, err := storage.CountBanEventsByCountry(ctx, since, serverID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - recurring, err := storage.ListRecurringIPStats(ctx, since, minCount, limit) + recurring, err := storage.ListRecurringIPStats(ctx, since, minCount, limit, serverID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - totalOverall, err := storage.CountBanEvents(ctx, time.Time{}) + totalOverall, err := storage.CountBanEvents(ctx, time.Time{}, serverID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -285,13 +286,13 @@ func BanInsightsHandler(c *gin.Context) { now := time.Now().UTC() - totalToday, err := storage.CountBanEvents(ctx, now.Add(-24*time.Hour)) + totalToday, err := storage.CountBanEvents(ctx, now.Add(-24*time.Hour), serverID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - totalWeek, err := storage.CountBanEvents(ctx, now.Add(-7*24*time.Hour)) + totalWeek, err := storage.CountBanEvents(ctx, now.Add(-7*24*time.Hour), serverID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 92d91b6..4e39b6e 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -133,7 +133,7 @@ /* Custom mark styling for search highlights */ mark { background-color: #fef08a; - padding: 0.1em 0.2em; + padding: 0.1em 0em 0.1em 0.2em; border-radius: 0.25em; } @@ -949,12 +949,31 @@ countries: [], recurring: [] }; + var latestServerInsights = null; var banEventsFilterText = ''; var banEventsFilterCountry = 'all'; var banEventsFilterDebounce = null; var translations = {}; var sshKeysCache = null; + function normalizeInsights(data) { + var normalized = data && typeof data === 'object' ? data : {}; + if (!normalized.totals || typeof normalized.totals !== 'object') { + normalized.totals = { overall: 0, today: 0, week: 0 }; + } else { + normalized.totals.overall = typeof normalized.totals.overall === 'number' ? normalized.totals.overall : 0; + normalized.totals.today = typeof normalized.totals.today === 'number' ? normalized.totals.today : 0; + normalized.totals.week = typeof normalized.totals.week === 'number' ? normalized.totals.week : 0; + } + if (!Array.isArray(normalized.countries)) { + normalized.countries = []; + } + if (!Array.isArray(normalized.recurring)) { + normalized.recurring = []; + } + return normalized; + } + function t(key, fallback) { if (translations && Object.prototype.hasOwnProperty.call(translations, key) && translations[key]) { return translations[key]; @@ -1354,20 +1373,37 @@ } function fetchBanInsightsData() { - return fetch('/api/events/bans/insights') + var sevenDaysAgo = new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString(); + var sinceQuery = '?since=' + encodeURIComponent(sevenDaysAgo); + var globalPromise = fetch('/api/events/bans/insights' + sinceQuery) .then(function(res) { return res.json(); }) .then(function(data) { - latestBanInsights = data || {}; - latestBanInsights.totals = latestBanInsights.totals || { overall: 0, today: 0, week: 0 }; - latestBanInsights.countries = latestBanInsights.countries || []; - latestBanInsights.recurring = latestBanInsights.recurring || []; + latestBanInsights = normalizeInsights(data); }) .catch(function(err) { console.error('Error fetching ban insights:', err); if (!latestBanInsights) { - latestBanInsights = { totals: { overall: 0, today: 0, week: 0 }, countries: [], recurring: [] }; + latestBanInsights = normalizeInsights(null); } }); + + var serverPromise; + if (currentServerId) { + serverPromise = fetch(withServerParam('/api/events/bans/insights' + sinceQuery)) + .then(function(res) { return res.json(); }) + .then(function(data) { + latestServerInsights = normalizeInsights(data); + }) + .catch(function(err) { + console.error('Error fetching server-specific ban insights:', err); + latestServerInsights = null; + }); + } else { + latestServerInsights = null; + serverPromise = Promise.resolve(); + } + + return Promise.all([globalPromise, serverPromise]); } function formatDateTime(value) { @@ -1411,25 +1447,11 @@ } function recurringIPsLastWeekCount() { - if (!latestBanInsights || !Array.isArray(latestBanInsights.recurring)) { + var source = latestServerInsights || latestBanInsights; + if (!source || !Array.isArray(source.recurring)) { return 0; } - var cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000); - var seen = {}; - var count = 0; - latestBanInsights.recurring.forEach(function(stat) { - if (!stat || !stat.ip) { - return; - } - var lastSeenTime = stat.lastSeen ? new Date(stat.lastSeen).getTime() : NaN; - if (isNaN(lastSeenTime) || lastSeenTime >= cutoff) { - if (!seen[stat.ip]) { - seen[stat.ip] = true; - count += 1; - } - } - }); - return count; + return source.recurring.length; } function captureFocusState(container) { @@ -1460,7 +1482,13 @@ if (!next) { return; } - next.focus(); + if (typeof next.focus === 'function') { + try { + next.focus({ preventScroll: true }); + } catch (err) { + next.focus(); + } + } try { if (typeof state.selectionStart === 'number' && typeof state.selectionEnd === 'number' && typeof next.setSelectionRange === 'function') { next.setSelectionRange(state.selectionStart, state.selectionEnd); @@ -1522,19 +1550,24 @@ }); } - function updateBanEventsSearch(value) { - banEventsFilterText = value || ''; + function scheduleLogOverviewRender() { if (banEventsFilterDebounce) { clearTimeout(banEventsFilterDebounce); } banEventsFilterDebounce = setTimeout(function() { - renderDashboard(); - }, 200); + renderLogOverviewSection(); + banEventsFilterDebounce = null; + }, 100); + } + + function updateBanEventsSearch(value) { + banEventsFilterText = value || ''; + scheduleLogOverviewRender(); } function updateBanEventsCountry(value) { banEventsFilterCountry = value || 'all'; - renderDashboard(); + scheduleLogOverviewRender(); } function getRecurringIPMap() { @@ -1562,6 +1595,7 @@ + '
Add a server to start monitoring and controlling Fail2ban instances.
' + ''; if (typeof updateTranslations === 'function') updateTranslations(); + restoreFocusState(focusState); return; } if (!enabledServers.length) { @@ -1571,6 +1605,7 @@ + 'Enable the local connector or register a remote Fail2ban server to see live data.
' + ''; if (typeof updateTranslations === 'function') updateTranslations(); + restoreFocusState(focusState); return; } @@ -1667,7 +1702,7 @@ html += ''; // close overview card } - html += renderLogOverview(); + html += '