mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Implement filtering for ban event-history, simple aggregation and insights
This commit is contained in:
@@ -39,8 +39,16 @@
|
|||||||
"logs.overview.total_events": "Gespeicherte Ereignisse gesamt",
|
"logs.overview.total_events": "Gespeicherte Ereignisse gesamt",
|
||||||
"logs.overview.per_server": "Ereignisse pro Server",
|
"logs.overview.per_server": "Ereignisse pro Server",
|
||||||
"logs.overview.recent_events_title": "Letzte gespeicherte Ereignisse",
|
"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.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.server": "Server",
|
||||||
"logs.table.count": "Anzahl",
|
"logs.table.count": "Anzahl",
|
||||||
"logs.table.jail": "Jail",
|
"logs.table.jail": "Jail",
|
||||||
@@ -50,9 +58,21 @@
|
|||||||
"logs.table.actions": "Aktionen",
|
"logs.table.actions": "Aktionen",
|
||||||
"logs.actions.whois": "Whois",
|
"logs.actions.whois": "Whois",
|
||||||
"logs.actions.logs": "Logs",
|
"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.whois_title": "Whois-Informationen",
|
||||||
"logs.modal.logs_title": "Logs",
|
"logs.modal.logs_title": "Logs",
|
||||||
"logs.modal.jail": "Jail",
|
"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.title": "Filter-Debug",
|
||||||
"filter_debug.select_filter": "Wählen Sie einen Filter",
|
"filter_debug.select_filter": "Wählen Sie einen Filter",
|
||||||
"filter_debug.log_lines": "Logzeilen",
|
"filter_debug.log_lines": "Logzeilen",
|
||||||
|
|||||||
@@ -39,8 +39,16 @@
|
|||||||
"logs.overview.total_events": "Total gspeichereti Ereigniss",
|
"logs.overview.total_events": "Total gspeichereti Ereigniss",
|
||||||
"logs.overview.per_server": "Ereigniss pro Server",
|
"logs.overview.per_server": "Ereigniss pro Server",
|
||||||
"logs.overview.recent_events_title": "Letschti gspeichereti Ereigniss",
|
"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.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.server": "Server",
|
||||||
"logs.table.count": "Aazahl",
|
"logs.table.count": "Aazahl",
|
||||||
"logs.table.jail": "Jail",
|
"logs.table.jail": "Jail",
|
||||||
@@ -50,9 +58,21 @@
|
|||||||
"logs.table.actions": "Aktione",
|
"logs.table.actions": "Aktione",
|
||||||
"logs.actions.whois": "Whois",
|
"logs.actions.whois": "Whois",
|
||||||
"logs.actions.logs": "Logs",
|
"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.whois_title": "Whois-Informatione",
|
||||||
"logs.modal.logs_title": "Logs",
|
"logs.modal.logs_title": "Logs",
|
||||||
"logs.modal.jail": "Jail",
|
"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.title": "Filter Debug",
|
||||||
"filter_debug.select_filter": "Wähl en Filter us",
|
"filter_debug.select_filter": "Wähl en Filter us",
|
||||||
"filter_debug.log_lines": "Log-Zile",
|
"filter_debug.log_lines": "Log-Zile",
|
||||||
|
|||||||
@@ -39,8 +39,16 @@
|
|||||||
"logs.overview.total_events": "Total stored events",
|
"logs.overview.total_events": "Total stored events",
|
||||||
"logs.overview.per_server": "Events per server",
|
"logs.overview.per_server": "Events per server",
|
||||||
"logs.overview.recent_events_title": "Recent stored events",
|
"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.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.server": "Server",
|
||||||
"logs.table.count": "Count",
|
"logs.table.count": "Count",
|
||||||
"logs.table.jail": "Jail",
|
"logs.table.jail": "Jail",
|
||||||
@@ -50,9 +58,21 @@
|
|||||||
"logs.table.actions": "Actions",
|
"logs.table.actions": "Actions",
|
||||||
"logs.actions.whois": "Whois",
|
"logs.actions.whois": "Whois",
|
||||||
"logs.actions.logs": "Logs",
|
"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.whois_title": "Whois Information",
|
||||||
"logs.modal.logs_title": "Logs",
|
"logs.modal.logs_title": "Logs",
|
||||||
"logs.modal.jail": "Jail",
|
"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.title": "Filter Debug",
|
||||||
"filter_debug.select_filter": "Select a Filter",
|
"filter_debug.select_filter": "Select a Filter",
|
||||||
"filter_debug.log_lines": "Log Lines",
|
"filter_debug.log_lines": "Log Lines",
|
||||||
|
|||||||
@@ -39,8 +39,16 @@
|
|||||||
"logs.overview.total_events": "Eventos almacenados totales",
|
"logs.overview.total_events": "Eventos almacenados totales",
|
||||||
"logs.overview.per_server": "Eventos por servidor",
|
"logs.overview.per_server": "Eventos por servidor",
|
||||||
"logs.overview.recent_events_title": "Eventos almacenados recientes",
|
"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.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.server": "Servidor",
|
||||||
"logs.table.count": "Cantidad",
|
"logs.table.count": "Cantidad",
|
||||||
"logs.table.jail": "Jail",
|
"logs.table.jail": "Jail",
|
||||||
@@ -50,9 +58,21 @@
|
|||||||
"logs.table.actions": "Acciones",
|
"logs.table.actions": "Acciones",
|
||||||
"logs.actions.whois": "Whois",
|
"logs.actions.whois": "Whois",
|
||||||
"logs.actions.logs": "Registros",
|
"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.whois_title": "Información Whois",
|
||||||
"logs.modal.logs_title": "Registros",
|
"logs.modal.logs_title": "Registros",
|
||||||
"logs.modal.jail": "Jail",
|
"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.title": "Depuración de filtros",
|
||||||
"filter_debug.select_filter": "Selecciona un filtro",
|
"filter_debug.select_filter": "Selecciona un filtro",
|
||||||
"filter_debug.log_lines": "Líneas de log",
|
"filter_debug.log_lines": "Líneas de log",
|
||||||
|
|||||||
@@ -39,8 +39,16 @@
|
|||||||
"logs.overview.total_events": "Total d'événements enregistrés",
|
"logs.overview.total_events": "Total d'événements enregistrés",
|
||||||
"logs.overview.per_server": "Événements par serveur",
|
"logs.overview.per_server": "Événements par serveur",
|
||||||
"logs.overview.recent_events_title": "Événements enregistrés récents",
|
"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.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.server": "Serveur",
|
||||||
"logs.table.count": "Nombre",
|
"logs.table.count": "Nombre",
|
||||||
"logs.table.jail": "Jail",
|
"logs.table.jail": "Jail",
|
||||||
@@ -50,9 +58,21 @@
|
|||||||
"logs.table.actions": "Actions",
|
"logs.table.actions": "Actions",
|
||||||
"logs.actions.whois": "Whois",
|
"logs.actions.whois": "Whois",
|
||||||
"logs.actions.logs": "Journaux",
|
"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.whois_title": "Informations Whois",
|
||||||
"logs.modal.logs_title": "Journaux",
|
"logs.modal.logs_title": "Journaux",
|
||||||
"logs.modal.jail": "Jail",
|
"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.title": "Débogage des filtres",
|
||||||
"filter_debug.select_filter": "Sélectionnez un filtre",
|
"filter_debug.select_filter": "Sélectionnez un filtre",
|
||||||
"filter_debug.log_lines": "Lignes de log",
|
"filter_debug.log_lines": "Lignes de log",
|
||||||
|
|||||||
@@ -39,8 +39,16 @@
|
|||||||
"logs.overview.total_events": "Eventi memorizzati totali",
|
"logs.overview.total_events": "Eventi memorizzati totali",
|
||||||
"logs.overview.per_server": "Eventi per server",
|
"logs.overview.per_server": "Eventi per server",
|
||||||
"logs.overview.recent_events_title": "Eventi memorizzati recenti",
|
"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.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.server": "Server",
|
||||||
"logs.table.count": "Conteggio",
|
"logs.table.count": "Conteggio",
|
||||||
"logs.table.jail": "Jail",
|
"logs.table.jail": "Jail",
|
||||||
@@ -50,9 +58,21 @@
|
|||||||
"logs.table.actions": "Azioni",
|
"logs.table.actions": "Azioni",
|
||||||
"logs.actions.whois": "Whois",
|
"logs.actions.whois": "Whois",
|
||||||
"logs.actions.logs": "Log",
|
"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.whois_title": "Informazioni Whois",
|
||||||
"logs.modal.logs_title": "Log",
|
"logs.modal.logs_title": "Log",
|
||||||
"logs.modal.jail": "Jail",
|
"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.title": "Debug Filtro",
|
||||||
"filter_debug.select_filter": "Seleziona un filtro",
|
"filter_debug.select_filter": "Seleziona un filtro",
|
||||||
"filter_debug.log_lines": "Righe di log",
|
"filter_debug.log_lines": "Righe di log",
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ type BanEventRecord struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
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.
|
// Init initializes the internal storage. Safe to call multiple times.
|
||||||
func Init(dbPath string) error {
|
func Init(dbPath string) error {
|
||||||
initOnce.Do(func() {
|
initOnce.Do(func() {
|
||||||
@@ -529,6 +537,124 @@ WHERE 1=1`
|
|||||||
return result, rows.Err()
|
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 {
|
func ensureSchema(ctx context.Context) error {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return errors.New("storage not initialised")
|
return errors.New("storage not initialised")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"net/smtp"
|
"net/smtp"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -239,6 +240,94 @@ func BanStatisticsHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"counts": stats})
|
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.
|
// ListServersHandler returns configured Fail2ban servers.
|
||||||
func ListServersHandler(c *gin.Context) {
|
func ListServersHandler(c *gin.Context) {
|
||||||
servers := config.ListServers()
|
servers := config.ListServers()
|
||||||
|
|||||||
@@ -68,5 +68,6 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
// Internal database overview
|
// Internal database overview
|
||||||
api.GET("/events/bans", ListBanEventsHandler)
|
api.GET("/events/bans", ListBanEventsHandler)
|
||||||
api.GET("/events/bans/stats", BanStatisticsHandler)
|
api.GET("/events/bans/stats", BanStatisticsHandler)
|
||||||
|
api.GET("/events/bans/insights", BanInsightsHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -885,6 +885,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Ban Insights Modal -->
|
||||||
|
<div id="banInsightsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||||
|
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
|
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
||||||
|
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<div class="sm:flex sm:items-start">
|
||||||
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="logs.modal.insights_title">Ban Insights</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500" data-i18n="logs.modal.insights_description">Country distribution and recurring offenders.</p>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="text-md font-semibold text-gray-800 mb-2" data-i18n="logs.modal.insights_countries">Bans by country</h4>
|
||||||
|
<div id="countryStatsContainer" class="max-h-64 overflow-y-auto divide-y divide-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="text-md font-semibold text-gray-800 mb-2" data-i18n="logs.modal.insights_recurring">Recurring IPs</h4>
|
||||||
|
<div id="recurringIPsContainer" class="max-h-64 overflow-y-auto divide-y divide-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
|
<button type="button" class="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('banInsightsModal')" data-i18n="modal.close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ********************** Modal Templates END ************************ -->
|
<!-- ********************** Modal Templates END ************************ -->
|
||||||
|
|
||||||
<!-- jQuery (used by Select2) -->
|
<!-- jQuery (used by Select2) -->
|
||||||
@@ -909,6 +944,14 @@
|
|||||||
var latestSummaryError = null;
|
var latestSummaryError = null;
|
||||||
var latestBanStats = {};
|
var latestBanStats = {};
|
||||||
var latestBanEvents = [];
|
var latestBanEvents = [];
|
||||||
|
var latestBanInsights = {
|
||||||
|
totals: { overall: 0, today: 0, week: 0 },
|
||||||
|
countries: [],
|
||||||
|
recurring: []
|
||||||
|
};
|
||||||
|
var banEventsFilterText = '';
|
||||||
|
var banEventsFilterCountry = 'all';
|
||||||
|
var banEventsFilterDebounce = null;
|
||||||
var translations = {};
|
var translations = {};
|
||||||
var sshKeysCache = null;
|
var sshKeysCache = null;
|
||||||
|
|
||||||
@@ -1233,13 +1276,14 @@
|
|||||||
function refreshData(options) {
|
function refreshData(options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
|
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
|
||||||
|
|
||||||
|
var summaryPromise;
|
||||||
if (!serversCache.length || !enabledServers.length || !currentServerId) {
|
if (!serversCache.length || !enabledServers.length || !currentServerId) {
|
||||||
latestSummary = null;
|
latestSummary = null;
|
||||||
latestSummaryError = null;
|
latestSummaryError = null;
|
||||||
latestBanStats = {};
|
summaryPromise = Promise.resolve();
|
||||||
latestBanEvents = [];
|
} else {
|
||||||
renderDashboard();
|
summaryPromise = fetchSummaryData();
|
||||||
return Promise.resolve();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.silent) {
|
if (!options.silent) {
|
||||||
@@ -1247,9 +1291,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
fetchSummaryData(),
|
summaryPromise,
|
||||||
fetchBanStatisticsData(),
|
fetchBanStatisticsData(),
|
||||||
fetchBanEventsData()
|
fetchBanEventsData(),
|
||||||
|
fetchBanInsightsData()
|
||||||
])
|
])
|
||||||
.then(function() {
|
.then(function() {
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
@@ -1285,7 +1330,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchBanStatisticsData() {
|
function fetchBanStatisticsData() {
|
||||||
return fetch(withServerParam('/api/events/bans/stats'))
|
return fetch('/api/events/bans/stats')
|
||||||
.then(function(res) { return res.json(); })
|
.then(function(res) { return res.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
latestBanStats = data && data.counts ? data.counts : {};
|
latestBanStats = data && data.counts ? data.counts : {};
|
||||||
@@ -1297,7 +1342,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchBanEventsData() {
|
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(res) { return res.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
latestBanEvents = data && data.events ? data.events : [];
|
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) {
|
function formatDateTime(value) {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
var date = new Date(value);
|
var date = new Date(value);
|
||||||
@@ -1325,12 +1387,108 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function totalStoredBans() {
|
function totalStoredBans() {
|
||||||
|
if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.overall === 'number') {
|
||||||
|
return latestBanInsights.totals.overall;
|
||||||
|
}
|
||||||
if (!latestBanStats) return 0;
|
if (!latestBanStats) return 0;
|
||||||
return Object.keys(latestBanStats).reduce(function(sum, key) {
|
return Object.keys(latestBanStats).reduce(function(sum, key) {
|
||||||
return sum + (latestBanStats[key] || 0);
|
return sum + (latestBanStats[key] || 0);
|
||||||
}, 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() {
|
function renderDashboard() {
|
||||||
var container = document.getElementById('dashboard');
|
var container = document.getElementById('dashboard');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -1370,11 +1528,7 @@
|
|||||||
+ '<div class="bg-white rounded-lg shadow p-6 mb-6">'
|
+ '<div class="bg-white rounded-lg shadow p-6 mb-6">'
|
||||||
+ ' <p class="text-gray-500" data-i18n="dashboard.loading_summary">Loading summary data…</p>'
|
+ ' <p class="text-gray-500" data-i18n="dashboard.loading_summary">Loading summary data…</p>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
container.innerHTML = html;
|
} else {
|
||||||
if (typeof updateTranslations === 'function') updateTranslations();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalBanned = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.totalBanned || 0); }, 0) : 0;
|
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 newLastHour = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.newInLastHour || 0); }, 0) : 0;
|
||||||
var totalStored = totalStoredBans();
|
var totalStored = totalStoredBans();
|
||||||
@@ -1448,6 +1602,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html += '</div>'; // close overview card
|
html += '</div>'; // close overview card
|
||||||
|
}
|
||||||
|
|
||||||
html += renderLogOverview();
|
html += renderLogOverview();
|
||||||
|
|
||||||
@@ -1486,14 +1641,36 @@
|
|||||||
+ ' </div>';
|
+ ' </div>';
|
||||||
|
|
||||||
var statsKeys = Object.keys(latestBanStats || {});
|
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 += '<p class="text-gray-500" data-i18n="logs.overview.empty">No ban events recorded yet.</p>';
|
html += '<p class="text-gray-500" data-i18n="logs.overview.empty">No ban events recorded yet.</p>';
|
||||||
} else {
|
} else {
|
||||||
html += ''
|
html += ''
|
||||||
+ '<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">'
|
+ '<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">'
|
||||||
+ ' <div class="border border-gray-200 rounded-lg p-4">'
|
+ ' <div class="border border-gray-200 rounded-lg p-4 flex flex-col gap-4">'
|
||||||
|
+ ' <div class="flex items-start justify-between gap-4">'
|
||||||
|
+ ' <div>'
|
||||||
+ ' <p class="text-sm text-gray-500" data-i18n="logs.overview.total_events">Total stored events</p>'
|
+ ' <p class="text-sm text-gray-500" data-i18n="logs.overview.total_events">Total stored events</p>'
|
||||||
+ ' <p class="text-2xl font-semibold text-gray-800">' + totalStoredBans() + '</p>'
|
+ ' <p class="text-2xl font-semibold text-gray-800">' + totalStored + '</p>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' <button type="button" class="inline-flex items-center px-3 py-1 text-sm rounded border border-blue-200 text-blue-600 hover:bg-blue-50" onclick="openBanInsightsModal()" data-i18n="logs.overview.open_insights">Open insights</button>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' <div class="grid grid-cols-2 gap-4 text-sm">'
|
||||||
|
+ ' <div>'
|
||||||
|
+ ' <p class="text-gray-500" data-i18n="logs.overview.total_today">Today</p>'
|
||||||
|
+ ' <p class="text-lg font-semibold text-gray-900">' + todayCount + '</p>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' <div>'
|
||||||
|
+ ' <p class="text-gray-500" data-i18n="logs.overview.total_week">Last 7 days</p>'
|
||||||
|
+ ' <p class="text-lg font-semibold text-gray-900">' + weekCount + '</p>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' </div>'
|
||||||
+ ' </div>'
|
+ ' </div>'
|
||||||
+ ' <div class="border border-gray-200 rounded-lg p-4 overflow-x-auto">'
|
+ ' <div class="border border-gray-200 rounded-lg p-4 overflow-x-auto">'
|
||||||
+ ' <p class="text-sm text-gray-500 mb-2" data-i18n="logs.overview.per_server">Events per server</p>'
|
+ ' <p class="text-sm text-gray-500 mb-2" data-i18n="logs.overview.per_server">Events per server</p>'
|
||||||
@@ -1505,6 +1682,9 @@
|
|||||||
+ ' </tr>'
|
+ ' </tr>'
|
||||||
+ ' </thead>'
|
+ ' </thead>'
|
||||||
+ ' <tbody>';
|
+ ' <tbody>';
|
||||||
|
if (!statsKeys.length) {
|
||||||
|
html += '<tr><td colspan="2" class="py-2 text-sm text-gray-500" data-i18n="logs.overview.per_server_empty">No per-server data available yet.</td></tr>';
|
||||||
|
} else {
|
||||||
statsKeys.forEach(function(serverId) {
|
statsKeys.forEach(function(serverId) {
|
||||||
var count = latestBanStats[serverId] || 0;
|
var count = latestBanStats[serverId] || 0;
|
||||||
var server = serversCache.find(function(s) { return s.id === serverId; });
|
var server = serversCache.find(function(s) { return s.id === serverId; });
|
||||||
@@ -1514,13 +1694,46 @@
|
|||||||
+ ' <td class="py-1">' + count + '</td>'
|
+ ' <td class="py-1">' + count + '</td>'
|
||||||
+ ' </tr>';
|
+ ' </tr>';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
html += ' </tbody></table></div></div>';
|
html += ' </tbody></table></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<h4 class="text-md font-semibold text-gray-800 mb-3" data-i18n="logs.overview.recent_events_title">Recent stored events</h4>';
|
html += '<h4 class="text-md font-semibold text-gray-800 mb-3" data-i18n="logs.overview.recent_events_title">Recent stored events</h4>';
|
||||||
|
|
||||||
if (!latestBanEvents.length) {
|
if (!latestBanEvents.length) {
|
||||||
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_empty">No stored events found for the selected server.</p>';
|
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_empty">No stored events found.</p>';
|
||||||
|
} else {
|
||||||
|
var countries = getBanEventCountries();
|
||||||
|
var filteredEvents = getFilteredBanEvents();
|
||||||
|
var recurringMap = getRecurringIPMap();
|
||||||
|
|
||||||
|
html += ''
|
||||||
|
+ '<div class="flex flex-col sm:flex-row gap-3 mb-4">'
|
||||||
|
+ ' <div class="flex-1">'
|
||||||
|
+ ' <label for="recentEventsSearch" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="logs.search.label">Search events</label>'
|
||||||
|
+ ' <input type="text" id="recentEventsSearch" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="' + t('logs.search.placeholder', 'Search IP, jail or server') + '" value="' + escapeHtml(banEventsFilterText) + '" oninput="updateBanEventsSearch(this.value)">'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' <div class="w-full sm:w-48">'
|
||||||
|
+ ' <label for="recentEventsCountry" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="logs.search.country_label">Country</label>'
|
||||||
|
+ ' <select id="recentEventsCountry" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="updateBanEventsCountry(this.value)">'
|
||||||
|
+ ' <option value="all"' + (banEventsFilterCountry === 'all' ? ' selected' : '') + ' data-i18n="logs.search.country_all">All countries</option>';
|
||||||
|
|
||||||
|
countries.forEach(function(country) {
|
||||||
|
var value = (country || '').trim();
|
||||||
|
var optionValue = value ? value.toLowerCase() : '__unknown__';
|
||||||
|
var label = value || t('logs.search.country_unknown', 'Unknown');
|
||||||
|
var selected = banEventsFilterCountry.toLowerCase() === optionValue ? ' selected' : '';
|
||||||
|
html += '<option value="' + optionValue + '"' + selected + '>' + escapeHtml(label) + '</option>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += ' </select>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ '</div>';
|
||||||
|
|
||||||
|
html += '<p class="text-xs text-gray-500 mb-3">' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + filteredEvents.length + ' / ' + latestBanEvents.length + '</p>';
|
||||||
|
|
||||||
|
if (!filteredEvents.length) {
|
||||||
|
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_filtered_empty">No stored events match the current filters.</p>';
|
||||||
} else {
|
} else {
|
||||||
html += ''
|
html += ''
|
||||||
+ '<div class="overflow-x-auto">'
|
+ '<div class="overflow-x-auto">'
|
||||||
@@ -1536,15 +1749,20 @@
|
|||||||
+ ' </tr>'
|
+ ' </tr>'
|
||||||
+ ' </thead>'
|
+ ' </thead>'
|
||||||
+ ' <tbody class="bg-white divide-y divide-gray-200">';
|
+ ' <tbody class="bg-white divide-y divide-gray-200">';
|
||||||
latestBanEvents.forEach(function(event, index) {
|
filteredEvents.forEach(function(event) {
|
||||||
|
var index = latestBanEvents.indexOf(event);
|
||||||
var hasWhois = event.whois && event.whois.trim().length > 0;
|
var hasWhois = event.whois && event.whois.trim().length > 0;
|
||||||
var hasLogs = event.logs && event.logs.trim().length > 0;
|
var hasLogs = event.logs && event.logs.trim().length > 0;
|
||||||
|
var ipCell = escapeHtml(event.ip || '');
|
||||||
|
if (event.ip && recurringMap[event.ip]) {
|
||||||
|
ipCell += ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">' + t('logs.badge.recurring', 'Recurring') + '</span>';
|
||||||
|
}
|
||||||
html += ''
|
html += ''
|
||||||
+ ' <tr class="hover:bg-gray-50">'
|
+ ' <tr class="hover:bg-gray-50">'
|
||||||
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '</td>'
|
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '</td>'
|
||||||
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(event.serverName || event.serverId || '') + '</td>'
|
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(event.serverName || event.serverId || '') + '</td>'
|
||||||
+ ' <td class="hidden sm:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.jail || '') + '</td>'
|
+ ' <td class="hidden sm:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.jail || '') + '</td>'
|
||||||
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(event.ip || '') + '</td>'
|
+ ' <td class="px-2 py-2 whitespace-nowrap">' + ipCell + '</td>'
|
||||||
+ ' <td class="hidden md:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.country || '—') + '</td>'
|
+ ' <td class="hidden md:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.country || '—') + '</td>'
|
||||||
+ ' <td class="px-2 py-2 whitespace-nowrap">'
|
+ ' <td class="px-2 py-2 whitespace-nowrap">'
|
||||||
+ ' <div class="flex gap-2">'
|
+ ' <div class="flex gap-2">'
|
||||||
@@ -1556,6 +1774,7 @@
|
|||||||
});
|
});
|
||||||
html += ' </tbody></table></div>';
|
html += ' </tbody></table></div>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
return html;
|
return html;
|
||||||
@@ -2239,6 +2458,55 @@
|
|||||||
return (hasBadStatus || hasIndicator) && !ip;
|
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 = '<p class="text-sm text-gray-500" data-i18n="logs.modal.insights_countries_empty">No bans recorded for this period.</p>';
|
||||||
|
} else {
|
||||||
|
var countryHTML = countries.map(function(stat) {
|
||||||
|
var label = stat.country || t('logs.overview.country_unknown', 'Unknown');
|
||||||
|
return ''
|
||||||
|
+ '<div class="flex items-center justify-between py-2">'
|
||||||
|
+ ' <span class="font-medium">' + escapeHtml(label) + '</span>'
|
||||||
|
+ ' <span class="text-sm text-gray-600">' + (stat.count || 0) + '</span>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
countriesContainer.innerHTML = countryHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
var recurring = (latestBanInsights && latestBanInsights.recurring) || [];
|
||||||
|
if (!recurring.length) {
|
||||||
|
recurringContainer.innerHTML = '<p class="text-sm text-gray-500" data-i18n="logs.modal.insights_recurring_empty">No recurring IPs detected.</p>';
|
||||||
|
} 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 ''
|
||||||
|
+ '<div class="py-2">'
|
||||||
|
+ ' <div class="flex items-center justify-between">'
|
||||||
|
+ ' <div>'
|
||||||
|
+ ' <p class="font-mono text-sm text-gray-900">' + escapeHtml(stat.ip || '—') + '</p>'
|
||||||
|
+ ' <p class="text-xs text-gray-500">' + escapeHtml(countryLabel) + '</p>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' <div class="text-right">'
|
||||||
|
+ ' <p class="text-sm font-semibold">' + (stat.count || 0) + '×</p>'
|
||||||
|
+ ' <p class="text-xs text-gray-500">' + t('logs.overview.last_seen', 'Last seen') + ': ' + escapeHtml(lastSeenLabel) + '</p>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
recurringContainer.innerHTML = recurringHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof updateTranslations === 'function') {
|
||||||
|
updateTranslations();
|
||||||
|
}
|
||||||
|
openModal('banInsightsModal');
|
||||||
|
}
|
||||||
|
|
||||||
// Function: openManageJailsModal
|
// Function: openManageJailsModal
|
||||||
// Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches.
|
// Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches.
|
||||||
function openManageJailsModal() {
|
function openManageJailsModal() {
|
||||||
|
|||||||
Reference in New Issue
Block a user