diff --git a/internal/locales/de.json b/internal/locales/de.json index 7d9c094..e637a55 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -39,8 +39,16 @@ "logs.overview.total_events": "Gespeicherte Ereignisse gesamt", "logs.overview.per_server": "Ereignisse pro Server", "logs.overview.recent_events_title": "Letzte gespeicherte Ereignisse", - "logs.overview.recent_empty": "Für den ausgewählten Server wurden keine gespeicherten Ereignisse gefunden.", + "logs.overview.recent_empty": "Keine gespeicherten Ereignisse gefunden.", "logs.overview.empty": "Es wurden noch keine Sperr-Ereignisse protokolliert.", + "logs.overview.open_insights": "Insights öffnen", + "logs.overview.total_today": "Heute", + "logs.overview.total_week": "Letzte 7 Tage", + "logs.overview.per_server_empty": "Noch keine Serverdaten verfügbar.", + "logs.overview.recent_filtered_empty": "Keine gespeicherten Ereignisse passen zu den Filtern.", + "logs.overview.recent_count_label": "Angezeigte Ereignisse", + "logs.overview.country_unknown": "Unbekannt", + "logs.overview.last_seen": "Zuletzt gesehen", "logs.table.server": "Server", "logs.table.count": "Anzahl", "logs.table.jail": "Jail", @@ -50,9 +58,21 @@ "logs.table.actions": "Aktionen", "logs.actions.whois": "Whois", "logs.actions.logs": "Logs", + "logs.search.label": "Ereignisse suchen", + "logs.search.placeholder": "Nach IP, Jail oder Server suchen", + "logs.search.country_label": "Land", + "logs.search.country_all": "Alle Länder", + "logs.search.country_unknown": "Unbekannt", + "logs.badge.recurring": "Wiederkehrend", "logs.modal.whois_title": "Whois-Informationen", "logs.modal.logs_title": "Logs", "logs.modal.jail": "Jail", + "logs.modal.insights_title": "Ban-Insights", + "logs.modal.insights_description": "Verteilung nach Ländern und wiederholte Angreifer.", + "logs.modal.insights_countries": "Sperren nach Land", + "logs.modal.insights_countries_empty": "Für diesen Zeitraum wurden keine Sperren erfasst.", + "logs.modal.insights_recurring": "Wiederkehrende IPs", + "logs.modal.insights_recurring_empty": "Keine wiederkehrenden IPs erkannt.", "filter_debug.title": "Filter-Debug", "filter_debug.select_filter": "Wählen Sie einen Filter", "filter_debug.log_lines": "Logzeilen", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 945d511..f50a0c1 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -39,8 +39,16 @@ "logs.overview.total_events": "Total gspeichereti Ereigniss", "logs.overview.per_server": "Ereigniss pro Server", "logs.overview.recent_events_title": "Letschti gspeichereti Ereigniss", - "logs.overview.recent_empty": "Kei gspeichereti Ereigniss für dä gwählte Server gfunde.", + "logs.overview.recent_empty": "Kei gspeichereti Ereigniss gfunde.", "logs.overview.empty": "No kei Sperr-Ereigniss protokolliert.", + "logs.overview.open_insights": "Insights azeige", + "logs.overview.total_today": "Hüt", + "logs.overview.total_week": "Letschti 7 Täg", + "logs.overview.per_server_empty": "No kei Serverdate verfügbar.", + "logs.overview.recent_filtered_empty": "Kei Ereigniss erfülle d Filter.", + "logs.overview.recent_count_label": "Aazeigti Ereigniss", + "logs.overview.country_unknown": "Unbekannt", + "logs.overview.last_seen": "Zletscht gseh", "logs.table.server": "Server", "logs.table.count": "Aazahl", "logs.table.jail": "Jail", @@ -50,9 +58,21 @@ "logs.table.actions": "Aktione", "logs.actions.whois": "Whois", "logs.actions.logs": "Logs", + "logs.search.label": "Ereigniss sueche", + "logs.search.placeholder": "IP, Jail oder Server sueche", + "logs.search.country_label": "Land", + "logs.search.country_all": "Alli Länder", + "logs.search.country_unknown": "Unbekannt", + "logs.badge.recurring": "Widerkehrend", "logs.modal.whois_title": "Whois-Informatione", "logs.modal.logs_title": "Logs", "logs.modal.jail": "Jail", + "logs.modal.insights_title": "Ban-Insights", + "logs.modal.insights_description": "Verteilig nach Länder und wiederholti Angreifer.", + "logs.modal.insights_countries": "Sperre nach Land", + "logs.modal.insights_countries_empty": "Kei Sperre i däm Zeitraum.", + "logs.modal.insights_recurring": "Wiederkehrendi IPs", + "logs.modal.insights_recurring_empty": "Kei wiederkehrendi IPs erkannt.", "filter_debug.title": "Filter Debug", "filter_debug.select_filter": "Wähl en Filter us", "filter_debug.log_lines": "Log-Zile", diff --git a/internal/locales/en.json b/internal/locales/en.json index a12f4c9..d5d3cd5 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -39,8 +39,16 @@ "logs.overview.total_events": "Total stored events", "logs.overview.per_server": "Events per server", "logs.overview.recent_events_title": "Recent stored events", - "logs.overview.recent_empty": "No stored events found for the selected server.", + "logs.overview.recent_empty": "No stored events found.", "logs.overview.empty": "No ban events recorded yet.", + "logs.overview.open_insights": "Open insights", + "logs.overview.total_today": "Today", + "logs.overview.total_week": "Last 7 days", + "logs.overview.per_server_empty": "No per-server data available yet.", + "logs.overview.recent_filtered_empty": "No stored events match the current filters.", + "logs.overview.recent_count_label": "Events shown", + "logs.overview.country_unknown": "Unknown", + "logs.overview.last_seen": "Last seen", "logs.table.server": "Server", "logs.table.count": "Count", "logs.table.jail": "Jail", @@ -50,9 +58,21 @@ "logs.table.actions": "Actions", "logs.actions.whois": "Whois", "logs.actions.logs": "Logs", + "logs.search.label": "Search events", + "logs.search.placeholder": "Search IP, jail or server", + "logs.search.country_label": "Country", + "logs.search.country_all": "All countries", + "logs.search.country_unknown": "Unknown", + "logs.badge.recurring": "Recurring", "logs.modal.whois_title": "Whois Information", "logs.modal.logs_title": "Logs", "logs.modal.jail": "Jail", + "logs.modal.insights_title": "Ban Insights", + "logs.modal.insights_description": "Country distribution and recurring offenders.", + "logs.modal.insights_countries": "Bans by country", + "logs.modal.insights_countries_empty": "No bans recorded for this period.", + "logs.modal.insights_recurring": "Recurring IPs", + "logs.modal.insights_recurring_empty": "No recurring IPs detected.", "filter_debug.title": "Filter Debug", "filter_debug.select_filter": "Select a Filter", "filter_debug.log_lines": "Log Lines", diff --git a/internal/locales/es.json b/internal/locales/es.json index e5a620a..bda6d3d 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -39,8 +39,16 @@ "logs.overview.total_events": "Eventos almacenados totales", "logs.overview.per_server": "Eventos por servidor", "logs.overview.recent_events_title": "Eventos almacenados recientes", - "logs.overview.recent_empty": "No se encontraron eventos almacenados para el servidor seleccionado.", + "logs.overview.recent_empty": "No se encontraron eventos almacenados.", "logs.overview.empty": "Aún no se han registrado eventos de bloqueo.", + "logs.overview.open_insights": "Abrir estadísticas", + "logs.overview.total_today": "Hoy", + "logs.overview.total_week": "Últimos 7 días", + "logs.overview.per_server_empty": "Aún no hay datos por servidor.", + "logs.overview.recent_filtered_empty": "No hay eventos que coincidan con los filtros.", + "logs.overview.recent_count_label": "Eventos mostrados", + "logs.overview.country_unknown": "Desconocido", + "logs.overview.last_seen": "Última vez", "logs.table.server": "Servidor", "logs.table.count": "Cantidad", "logs.table.jail": "Jail", @@ -50,9 +58,21 @@ "logs.table.actions": "Acciones", "logs.actions.whois": "Whois", "logs.actions.logs": "Registros", + "logs.search.label": "Buscar eventos", + "logs.search.placeholder": "Busca IP, jail o servidor", + "logs.search.country_label": "País", + "logs.search.country_all": "Todos los países", + "logs.search.country_unknown": "Desconocido", + "logs.badge.recurring": "Recurrente", "logs.modal.whois_title": "Información Whois", "logs.modal.logs_title": "Registros", "logs.modal.jail": "Jail", + "logs.modal.insights_title": "Información de bloqueos", + "logs.modal.insights_description": "Distribución por país y atacantes recurrentes.", + "logs.modal.insights_countries": "Bloqueos por país", + "logs.modal.insights_countries_empty": "No se registraron bloqueos en este periodo.", + "logs.modal.insights_recurring": "IPs recurrentes", + "logs.modal.insights_recurring_empty": "No se detectaron IPs recurrentes.", "filter_debug.title": "Depuración de filtros", "filter_debug.select_filter": "Selecciona un filtro", "filter_debug.log_lines": "Líneas de log", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index de51d7d..5c542f3 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -39,8 +39,16 @@ "logs.overview.total_events": "Total d'événements enregistrés", "logs.overview.per_server": "Événements par serveur", "logs.overview.recent_events_title": "Événements enregistrés récents", - "logs.overview.recent_empty": "Aucun événement enregistré trouvé pour le serveur sélectionné.", + "logs.overview.recent_empty": "Aucun événement stocké trouvé.", "logs.overview.empty": "Aucun événement de blocage n'a encore été enregistré.", + "logs.overview.open_insights": "Ouvrir les insights", + "logs.overview.total_today": "Aujourd'hui", + "logs.overview.total_week": "7 derniers jours", + "logs.overview.per_server_empty": "Aucune donnée par serveur pour le moment.", + "logs.overview.recent_filtered_empty": "Aucun événement ne correspond aux filtres.", + "logs.overview.recent_count_label": "Événements affichés", + "logs.overview.country_unknown": "Inconnu", + "logs.overview.last_seen": "Dernière apparition", "logs.table.server": "Serveur", "logs.table.count": "Nombre", "logs.table.jail": "Jail", @@ -50,9 +58,21 @@ "logs.table.actions": "Actions", "logs.actions.whois": "Whois", "logs.actions.logs": "Journaux", + "logs.search.label": "Rechercher des événements", + "logs.search.placeholder": "Rechercher IP, jail ou serveur", + "logs.search.country_label": "Pays", + "logs.search.country_all": "Tous les pays", + "logs.search.country_unknown": "Inconnu", + "logs.badge.recurring": "Récurrent", "logs.modal.whois_title": "Informations Whois", "logs.modal.logs_title": "Journaux", "logs.modal.jail": "Jail", + "logs.modal.insights_title": "Aperçu des blocages", + "logs.modal.insights_description": "Répartition par pays et IP récurrentes.", + "logs.modal.insights_countries": "Blocages par pays", + "logs.modal.insights_countries_empty": "Aucun blocage enregistré pour cette période.", + "logs.modal.insights_recurring": "IPs récurrentes", + "logs.modal.insights_recurring_empty": "Aucune IP récurrente détectée.", "filter_debug.title": "Débogage des filtres", "filter_debug.select_filter": "Sélectionnez un filtre", "filter_debug.log_lines": "Lignes de log", diff --git a/internal/locales/it.json b/internal/locales/it.json index f307e7b..23b2c94 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -39,8 +39,16 @@ "logs.overview.total_events": "Eventi memorizzati totali", "logs.overview.per_server": "Eventi per server", "logs.overview.recent_events_title": "Eventi memorizzati recenti", - "logs.overview.recent_empty": "Nessun evento memorizzato trovato per il server selezionato.", + "logs.overview.recent_empty": "Nessun evento memorizzato trovato.", "logs.overview.empty": "Nessun evento di blocco è stato ancora registrato.", + "logs.overview.open_insights": "Apri insights", + "logs.overview.total_today": "Oggi", + "logs.overview.total_week": "Ultimi 7 giorni", + "logs.overview.per_server_empty": "Ancora nessun dato per server.", + "logs.overview.recent_filtered_empty": "Nessun evento corrisponde ai filtri.", + "logs.overview.recent_count_label": "Eventi mostrati", + "logs.overview.country_unknown": "Sconosciuto", + "logs.overview.last_seen": "Ultima visualizzazione", "logs.table.server": "Server", "logs.table.count": "Conteggio", "logs.table.jail": "Jail", @@ -50,9 +58,21 @@ "logs.table.actions": "Azioni", "logs.actions.whois": "Whois", "logs.actions.logs": "Log", + "logs.search.label": "Cerca eventi", + "logs.search.placeholder": "Cerca IP, jail o server", + "logs.search.country_label": "Paese", + "logs.search.country_all": "Tutti i paesi", + "logs.search.country_unknown": "Sconosciuto", + "logs.badge.recurring": "Ricorrente", "logs.modal.whois_title": "Informazioni Whois", "logs.modal.logs_title": "Log", "logs.modal.jail": "Jail", + "logs.modal.insights_title": "Statistiche blocchi", + "logs.modal.insights_description": "Distribuzione per paese e IP ricorrenti.", + "logs.modal.insights_countries": "Blocchi per paese", + "logs.modal.insights_countries_empty": "Nessun blocco registrato per questo periodo.", + "logs.modal.insights_recurring": "IP ricorrenti", + "logs.modal.insights_recurring_empty": "Nessun IP ricorrente rilevato.", "filter_debug.title": "Debug Filtro", "filter_debug.select_filter": "Seleziona un filtro", "filter_debug.log_lines": "Righe di log", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 623fd42..0dfd002 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -104,6 +104,14 @@ type BanEventRecord struct { CreatedAt time.Time `json:"createdAt"` } +// RecurringIPStat represents aggregation info for repeatedly banned IPs. +type RecurringIPStat struct { + IP string `json:"ip"` + Country string `json:"country"` + Count int64 `json:"count"` + LastSeen time.Time `json:"lastSeen"` +} + // Init initializes the internal storage. Safe to call multiple times. func Init(dbPath string) error { initOnce.Do(func() { @@ -529,6 +537,124 @@ 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) { + if db == nil { + return 0, errors.New("storage not initialised") + } + + query := ` +SELECT COUNT(*) +FROM ban_events +WHERE 1=1` + args := []any{} + + if !since.IsZero() { + query += " AND occurred_at >= ?" + args = append(args, since.UTC()) + } + + var total int64 + if err := db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil { + return 0, err + } + return total, nil +} + +// CountBanEventsByCountry returns aggregation per country code. +func CountBanEventsByCountry(ctx context.Context, since time.Time) (map[string]int64, error) { + if db == nil { + return nil, errors.New("storage not initialised") + } + + query := ` +SELECT COALESCE(country, '') AS country, COUNT(*) +FROM ban_events +WHERE 1=1` + args := []any{} + + if !since.IsZero() { + query += " AND occurred_at >= ?" + args = append(args, since.UTC()) + } + + query += " GROUP BY COALESCE(country, '')" + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]int64) + for rows.Next() { + var country sql.NullString + var count int64 + if err := rows.Scan(&country, &count); err != nil { + return nil, err + } + result[stringFromNull(country)] = count + } + + 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) { + if db == nil { + return nil, errors.New("storage not initialised") + } + + if minCount < 2 { + minCount = 2 + } + if limit <= 0 || limit > 500 { + limit = 100 + } + + query := ` +SELECT ip, COALESCE(country, '') AS country, COUNT(*) AS cnt, MAX(occurred_at) AS last_seen +FROM ban_events +WHERE ip != ''` + args := []any{} + + if !since.IsZero() { + query += " AND occurred_at >= ?" + args = append(args, since.UTC()) + } + + query += ` +GROUP BY ip, COALESCE(country, '') +HAVING cnt >= ? +ORDER BY cnt DESC, last_seen DESC +LIMIT ?` + + args = append(args, minCount, limit) + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []RecurringIPStat + for rows.Next() { + var stat RecurringIPStat + var lastSeen sql.NullString + if err := rows.Scan(&stat.IP, &stat.Country, &stat.Count, &lastSeen); err != nil { + return nil, err + } + if lastSeen.Valid { + if parsed, err := time.Parse(time.RFC3339Nano, lastSeen.String); err == nil { + stat.LastSeen = parsed + } + } + results = append(results, stat) + } + + return results, rows.Err() +} + func ensureSchema(ctx context.Context) error { if db == nil { return errors.New("storage not initialised") diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index f9cdaf3..5cec32b 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -29,6 +29,7 @@ import ( "net/smtp" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -239,6 +240,94 @@ func BanStatisticsHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"counts": stats}) } +// BanInsightsHandler returns aggregate stats for countries and recurring IPs. +func BanInsightsHandler(c *gin.Context) { + var since time.Time + if sinceStr := c.Query("since"); sinceStr != "" { + if parsed, err := time.Parse(time.RFC3339, sinceStr); err == nil { + since = parsed + } + } + + minCount := 3 + if minCountStr := c.DefaultQuery("minCount", "3"); minCountStr != "" { + if parsed, err := strconv.Atoi(minCountStr); err == nil && parsed > 0 { + minCount = parsed + } + } + + limit := 50 + if limitStr := c.DefaultQuery("limit", "50"); limitStr != "" { + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { + limit = parsed + } + } + + ctx := c.Request.Context() + + countriesMap, err := storage.CountBanEventsByCountry(ctx, since) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + recurring, err := storage.ListRecurringIPStats(ctx, since, minCount, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + totalOverall, err := storage.CountBanEvents(ctx, time.Time{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + now := time.Now().UTC() + + totalToday, err := storage.CountBanEvents(ctx, now.Add(-24*time.Hour)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + totalWeek, err := storage.CountBanEvents(ctx, now.Add(-7*24*time.Hour)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + type countryStat struct { + Country string `json:"country"` + Count int64 `json:"count"` + } + + countries := make([]countryStat, 0, len(countriesMap)) + for country, count := range countriesMap { + countries = append(countries, countryStat{ + Country: country, + Count: count, + }) + } + + sort.Slice(countries, func(i, j int) bool { + if countries[i].Count == countries[j].Count { + return countries[i].Country < countries[j].Country + } + return countries[i].Count > countries[j].Count + }) + + c.JSON(http.StatusOK, gin.H{ + "countries": countries, + "recurring": recurring, + "totals": gin.H{ + "overall": totalOverall, + "today": totalToday, + "week": totalWeek, + }, + }) +} + // ListServersHandler returns configured Fail2ban servers. func ListServersHandler(c *gin.Context) { servers := config.ListServers() diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 4180caa..5a78803 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -68,5 +68,6 @@ func RegisterRoutes(r *gin.Engine) { // Internal database overview api.GET("/events/bans", ListBanEventsHandler) api.GET("/events/bans/stats", BanStatisticsHandler) + api.GET("/events/bans/insights", BanInsightsHandler) } } diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index e4c0076..5cad073 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -885,6 +885,41 @@ + + + @@ -909,6 +944,14 @@ var latestSummaryError = null; var latestBanStats = {}; var latestBanEvents = []; + var latestBanInsights = { + totals: { overall: 0, today: 0, week: 0 }, + countries: [], + recurring: [] + }; + var banEventsFilterText = ''; + var banEventsFilterCountry = 'all'; + var banEventsFilterDebounce = null; var translations = {}; var sshKeysCache = null; @@ -1233,13 +1276,14 @@ function refreshData(options) { options = options || {}; var enabledServers = serversCache.filter(function(s) { return s.enabled; }); + + var summaryPromise; if (!serversCache.length || !enabledServers.length || !currentServerId) { latestSummary = null; latestSummaryError = null; - latestBanStats = {}; - latestBanEvents = []; - renderDashboard(); - return Promise.resolve(); + summaryPromise = Promise.resolve(); + } else { + summaryPromise = fetchSummaryData(); } if (!options.silent) { @@ -1247,9 +1291,10 @@ } return Promise.all([ - fetchSummaryData(), + summaryPromise, fetchBanStatisticsData(), - fetchBanEventsData() + fetchBanEventsData(), + fetchBanInsightsData() ]) .then(function() { renderDashboard(); @@ -1285,7 +1330,7 @@ } function fetchBanStatisticsData() { - return fetch(withServerParam('/api/events/bans/stats')) + return fetch('/api/events/bans/stats') .then(function(res) { return res.json(); }) .then(function(data) { latestBanStats = data && data.counts ? data.counts : {}; @@ -1297,7 +1342,7 @@ } function fetchBanEventsData() { - return fetch(withServerParam('/api/events/bans?limit=25')) + return fetch('/api/events/bans?limit=200') .then(function(res) { return res.json(); }) .then(function(data) { latestBanEvents = data && data.events ? data.events : []; @@ -1308,6 +1353,23 @@ }); } + function fetchBanInsightsData() { + return fetch('/api/events/bans/insights') + .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 || []; + }) + .catch(function(err) { + console.error('Error fetching ban insights:', err); + if (!latestBanInsights) { + latestBanInsights = { totals: { overall: 0, today: 0, week: 0 }, countries: [], recurring: [] }; + } + }); + } + function formatDateTime(value) { if (!value) return ''; var date = new Date(value); @@ -1325,12 +1387,108 @@ } function totalStoredBans() { + if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.overall === 'number') { + return latestBanInsights.totals.overall; + } if (!latestBanStats) return 0; return Object.keys(latestBanStats).reduce(function(sum, key) { return sum + (latestBanStats[key] || 0); }, 0); } + function totalBansToday() { + if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.today === 'number') { + return latestBanInsights.totals.today; + } + return 0; + } + + function totalBansWeek() { + if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.week === 'number') { + return latestBanInsights.totals.week; + } + return 0; + } + + function getBanEventCountries() { + var countries = {}; + latestBanEvents.forEach(function(event) { + var country = (event.country || '').trim(); + var key = country.toLowerCase(); + if (!countries[key]) { + countries[key] = country; + } + }); + var keys = Object.keys(countries); + keys.sort(); + return keys.map(function(key) { + return countries[key]; + }); + } + + function getFilteredBanEvents() { + var text = (banEventsFilterText || '').toLowerCase(); + var countryFilter = (banEventsFilterCountry || '').toLowerCase(); + + return latestBanEvents.filter(function(event) { + var matchesCountry = !countryFilter || countryFilter === 'all'; + if (!matchesCountry) { + var eventCountryValue = (event.country || '').toLowerCase(); + if (!eventCountryValue) { + eventCountryValue = '__unknown__'; + } + matchesCountry = eventCountryValue === countryFilter; + } + + if (!text) { + return matchesCountry; + } + + var haystack = [ + event.ip, + event.jail, + event.serverName, + event.hostname, + event.country + ].map(function(value) { + return (value || '').toLowerCase(); + }); + + var matchesText = haystack.some(function(value) { + return value.indexOf(text) !== -1; + }); + + return matchesCountry && matchesText; + }); + } + + function updateBanEventsSearch(value) { + banEventsFilterText = value || ''; + if (banEventsFilterDebounce) { + clearTimeout(banEventsFilterDebounce); + } + banEventsFilterDebounce = setTimeout(function() { + renderDashboard(); + }, 200); + } + + function updateBanEventsCountry(value) { + banEventsFilterCountry = value || 'all'; + renderDashboard(); + } + + function getRecurringIPMap() { + var map = {}; + if (latestBanInsights && Array.isArray(latestBanInsights.recurring)) { + latestBanInsights.recurring.forEach(function(stat) { + if (stat && stat.ip) { + map[stat.ip] = stat; + } + }); + } + return map; + } + function renderDashboard() { var container = document.getElementById('dashboard'); if (!container) return; @@ -1370,85 +1528,82 @@ + '
' + '

Loading summary data…

' + '
'; - container.innerHTML = html; - if (typeof updateTranslations === 'function') updateTranslations(); - return; - } - - var totalBanned = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.totalBanned || 0); }, 0) : 0; - var newLastHour = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.newInLastHour || 0); }, 0) : 0; - var totalStored = totalStoredBans(); - - html += '' - + '
' - + '
' - + '

Active Jails

' - + '

' + (summary.jails ? summary.jails.length : 0) + '

' - + '
' - + '
' - + '

Total Banned IPs

' - + '

' + totalBanned + '

' - + '
' - + '
' - + '

New Last Hour

' - + '

' + newLastHour + '

' - + '
' - + '
' - + '

Stored Ban Events

' - + '

' + totalStored + '

' - + '
' - + '
'; - - html += '' - + '
' - + '
' - + '
' - + '

Overview active Jails and Blocks

' - + '

Use the search to filter banned IPs and click a jail to edit its configuration.

' - + '
' - + '
' - + ' ' - + ' ' - + '
' - + '
'; - - if (!summary.jails || summary.jails.length === 0) { - html += '

No jails found.

'; } else { + var totalBanned = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.totalBanned || 0); }, 0) : 0; + var newLastHour = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.newInLastHour || 0); }, 0) : 0; + var totalStored = totalStoredBans(); + html += '' - + '
' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' '; + + '
' + + '
' + + '

Active Jails

' + + '

' + (summary.jails ? summary.jails.length : 0) + '

' + + '
' + + '
' + + '

Total Banned IPs

' + + '

' + totalBanned + '

' + + '
' + + '
' + + '

New Last Hour

' + + '

' + newLastHour + '

' + + '
' + + '
' + + '

Stored Ban Events

' + + '

' + totalStored + '

' + + '
' + + '
'; - summary.jails.forEach(function(jail) { - var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs || []); + html += '' + + '
' + + '
' + + '
' + + '

Overview active Jails and Blocks

' + + '

Use the search to filter banned IPs and click a jail to edit its configuration.

' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
'; + + if (!summary.jails || summary.jails.length === 0) { + html += '

No jails found.

'; + } else { html += '' - + '
' - + ' ' - + ' ' - + ' ' - + ' ' - + ''; - }); + + '
' + + '
JailBanned IPs
' - + ' ' - + escapeHtml(jail.jailName) - + ' ' - + ' ' + bannedHTML + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' '; - html += '
JailBanned IPs
'; - html += '
'; + summary.jails.forEach(function(jail) { + var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs || []); + html += '' + + '' + + ' ' + + ' ' + + escapeHtml(jail.jailName) + + ' ' + + ' ' + + ' ' + (jail.totalBanned || 0) + '' + + ' ' + (jail.newInLastHour || 0) + '' + + ' ' + bannedHTML + '' + + ''; + }); + + html += ' '; + html += '
'; + } + + html += ''; // close overview card } - html += ''; // close overview card - html += renderLogOverview(); container.innerHTML = html; @@ -1486,14 +1641,36 @@ + ' '; var statsKeys = Object.keys(latestBanStats || {}); - if (statsKeys.length === 0) { + statsKeys.sort(function(a, b) { + return (latestBanStats[b] || 0) - (latestBanStats[a] || 0); + }); + var totalStored = totalStoredBans(); + var todayCount = totalBansToday(); + var weekCount = totalBansWeek(); + + if (statsKeys.length === 0 && totalStored === 0) { html += '

No ban events recorded yet.

'; } else { html += '' + '
' - + '
' - + '

Total stored events

' - + '

' + totalStoredBans() + '

' + + '
' + + '
' + + '
' + + '

Total stored events

' + + '

' + totalStored + '

' + + '
' + + ' ' + + '
' + + '
' + + '
' + + '

Today

' + + '

' + todayCount + '

' + + '
' + + '
' + + '

Last 7 days

' + + '

' + weekCount + '

' + + '
' + + '
' + '
' + '
' + '

Events per server

' @@ -1505,23 +1682,59 @@ + ' ' + ' ' + ' '; - statsKeys.forEach(function(serverId) { - var count = latestBanStats[serverId] || 0; - var server = serversCache.find(function(s) { return s.id === serverId; }); - html += '' - + ' ' - + ' ' + escapeHtml(server ? server.name : serverId) + '' - + ' ' + count + '' - + ' '; - }); + if (!statsKeys.length) { + html += 'No per-server data available yet.'; + } else { + statsKeys.forEach(function(serverId) { + var count = latestBanStats[serverId] || 0; + var server = serversCache.find(function(s) { return s.id === serverId; }); + html += '' + + ' ' + + ' ' + escapeHtml(server ? server.name : serverId) + '' + + ' ' + count + '' + + ' '; + }); + } html += '
'; } html += '

Recent stored events

'; if (!latestBanEvents.length) { - html += '

No stored events found for the selected server.

'; + html += '

No stored events found.

'; } else { + var countries = getBanEventCountries(); + var filteredEvents = getFilteredBanEvents(); + var recurringMap = getRecurringIPMap(); + + html += '' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
'; + + html += '

' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + filteredEvents.length + ' / ' + latestBanEvents.length + '

'; + + if (!filteredEvents.length) { + html += '

No stored events match the current filters.

'; + } else { html += '' + '
' + ' ' @@ -1536,15 +1749,20 @@ + ' ' + ' ' + ' '; - latestBanEvents.forEach(function(event, index) { + filteredEvents.forEach(function(event) { + var index = latestBanEvents.indexOf(event); var hasWhois = event.whois && event.whois.trim().length > 0; var hasLogs = event.logs && event.logs.trim().length > 0; + var ipCell = escapeHtml(event.ip || ''); + if (event.ip && recurringMap[event.ip]) { + ipCell += ' ' + t('logs.badge.recurring', 'Recurring') + ''; + } html += '' + ' ' + ' ' + ' ' + ' ' - + ' ' + + ' ' + ' ' + ' '; }); html += '
' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '' + escapeHtml(event.serverName || event.serverId || '') + '' + escapeHtml(event.ip || '') + '' + ipCell + '' + '
' @@ -1555,6 +1773,7 @@ + '
'; + } } html += '
'; @@ -2239,6 +2458,55 @@ return (hasBadStatus || hasIndicator) && !ip; } + function openBanInsightsModal() { + var countriesContainer = document.getElementById('countryStatsContainer'); + var recurringContainer = document.getElementById('recurringIPsContainer'); + + var countries = (latestBanInsights && latestBanInsights.countries) || []; + if (!countries.length) { + countriesContainer.innerHTML = '

No bans recorded for this period.

'; + } else { + var countryHTML = countries.map(function(stat) { + var label = stat.country || t('logs.overview.country_unknown', 'Unknown'); + return '' + + '
' + + ' ' + escapeHtml(label) + '' + + ' ' + (stat.count || 0) + '' + + '
'; + }).join(''); + countriesContainer.innerHTML = countryHTML; + } + + var recurring = (latestBanInsights && latestBanInsights.recurring) || []; + if (!recurring.length) { + recurringContainer.innerHTML = '

No recurring IPs detected.

'; + } else { + var recurringHTML = recurring.map(function(stat) { + var countryLabel = stat.country || t('logs.overview.country_unknown', 'Unknown'); + var lastSeenLabel = stat.lastSeen ? formatDateTime(stat.lastSeen) : '—'; + return '' + + '
' + + '
' + + '
' + + '

' + escapeHtml(stat.ip || '—') + '

' + + '

' + escapeHtml(countryLabel) + '

' + + '
' + + '
' + + '

' + (stat.count || 0) + '×

' + + '

' + t('logs.overview.last_seen', 'Last seen') + ': ' + escapeHtml(lastSeenLabel) + '

' + + '
' + + '
' + + '
'; + }).join(''); + recurringContainer.innerHTML = recurringHTML; + } + + if (typeof updateTranslations === 'function') { + updateTranslations(); + } + openModal('banInsightsModal'); + } + // Function: openManageJailsModal // Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches. function openManageJailsModal() {