Improve ban events search search through all db-entries and also implement pagination to load more events

This commit is contained in:
2026-02-03 14:31:52 +01:00
parent b3e32fd5c1
commit 5f14da5934
13 changed files with 494 additions and 225 deletions

276
README.md
View File

@@ -209,77 +209,11 @@ Modern enterprises face increasing security challenges with generally distribute
--- ---
## 🏗️ Architecture
### System Components
```
┌────────────────────────────────────────────────────────────┐
│ Fail2Ban UI Web Interface │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Dashboard │ │ Management │ │ Settings │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ Go Backend API Server │
│ ┌─────────────────────────────┐ ┌──────────────────────┐ │
│ │ Fail2Ban UI (Backend) │--->│ Send Alerts via Mail │ │
│ │ - Gin handlers + REST API │ │ (planned: Elastic) │ │
│ │ - Vanilla JS + Tailwind UI │ └──────────────────────┘ │
│ ->│ - SQLite storage │ │
│ │ └──────────────┬──────────────┘ │
│ │ │ │
│ │ ┌──────────┴────────────┐ ┌─────────────────────┐ │
│ │ │ Connector Manager and │-------│ Integrations │ │
│ │ │ handlers / actions │ │ Mikrotik / pfSense │ │
│ │ └────────────────────┬──┘ └─────────────────────┘ │
│ │ │ │
└─│───────────────────────────│──────────────────────────────────┘
│ │
│ ▼
┌─│─────────────────────────────────────────────────────────────┐
│ │ Connection to remote Server │
│ │ ───────────────────────────── │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ │ Local │ │ SSH │ │ API │ │
│ │ │ Server │ │ Server │ │ Agent │ │
│ │ └────────┘ └────────┘ └────────┘ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ ┌─────┴─────────────┴─────────────┴─────┐ │
│ │ │ Fail2Ban instances on Reverse Proxies │ │
│ │ │ or remote / local Webserver │ │
│ │ └─────────────┬─────────────────────────┘ │
│ │ │ │
│ │ ┌──────────┴────────────┐ │
│ │ │ Report Alerts back to │ │
│ <----------│ Fail2Ban-UI REST with │ │
│ │ custom action │ │
│ └───────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
```
### Technology Stack
- **Backend**: Go 1.24+ (Golang)
- **Frontend**: Vanilla JavaScript, Tailwind CSS
- **Database**: SQLite (embedded)
- **Container Runtime**: Podman/Docker compatible
- **Service Management**: systemd
- **Security**: SELinux compatible
---
## 🚀 Quick Start ## 🚀 Quick Start
### Prerequisites ### Prerequisites
- **Operating System**: Linux (RHEL 8+, Ubuntu 20.04+, Debian 11+, or containerized) - **System**: Linux (RHEL 8+, Ubuntu 20.04+, Debian 11+) or Container-Environment
- **Fail2Ban**: At least version 0.10+ installed and configured - **Fail2Ban**: At least version 0.10+ installed and configured
- **Go**: Version 1.24+ (only for source builds) - **Go**: Version 1.24+ (only for source builds)
- **Node.js**: Version 16+ (only for source build - Tailwind CSS) - **Node.js**: Version 16+ (only for source build - Tailwind CSS)
@@ -287,7 +221,7 @@ Modern enterprises face increasing security challenges with generally distribute
### Installation Methods (Example with mounts for local fail2ban connector) ### Installation Methods (Example with mounts for local fail2ban connector)
#### Method 1: Container Deployment (Recommended for Production) #### Method 1: Container Deployment (Recommended)
**Option A: Using Pre-built Image** **Option A: Using Pre-built Image**
@@ -302,7 +236,7 @@ docker pull swissmakers/fail2ban-ui:latest
# podman pull registry.swissmakers.ch/infra/fail2ban-ui:latest # podman pull registry.swissmakers.ch/infra/fail2ban-ui:latest
# docker pull registry.swissmakers.ch/infra/fail2ban-ui:latest # docker pull registry.swissmakers.ch/infra/fail2ban-ui:latest
# Run the container # Run the container (for usage with the local connector - fail2ban runs on same host)
podman run -d \ podman run -d \
--name fail2ban-ui \ --name fail2ban-ui \
--network=host \ --network=host \
@@ -326,7 +260,7 @@ sudo podman build -t fail2ban-ui:dev .
# or with Docker: # or with Docker:
sudo docker build -t fail2ban-ui:dev . sudo docker build -t fail2ban-ui:dev .
# Run the container # Run the container (for usage with the local connector - fail2ban runs on same host)
sudo podman run -d \ sudo podman run -d \
--name fail2ban-ui \ --name fail2ban-ui \
--network=host \ --network=host \
@@ -339,14 +273,14 @@ sudo podman run -d \
**Option C: Using Docker Compose** **Option C: Using Docker Compose**
For easier management, use Docker Compose: For a quicker start you can also use Docker Compose:
```bash ```bash
# Copy the example file # Copy the example file
cp docker-compose.example.yml docker-compose.yml cp docker-compose.example.yml docker-compose.yml
# or # or
cp docker-compose-allinone.example.yml docker-compose.yml cp docker-compose-allinone.example.yml docker-compose.yml
# Edit docker-compose.yml to customize (e.g., change PORT) # Edit docker-compose.yml to customize (e.g., change PORT and so on..)
# Then start: # Then start:
podman compose up -d podman compose up -d
# or # or
@@ -357,17 +291,37 @@ docker-compose up -d
Change the default port (8080) using the `PORT` environment variable: Change the default port (8080) using the `PORT` environment variable:
```bash ```bash
podman run -d \ -e PORT=3080 \
--name fail2ban-ui \
--network=host \
-e PORT=3080 \
-v /opt/podman-fail2ban-ui:/config:Z \
-v /etc/fail2ban:/etc/fail2ban:Z \
-v /var/log:/var/log:ro \
-v /var/run/fail2ban:/var/run/fail2ban \
swissmakers/fail2ban-ui:latest
``` ```
**Bind address**
By default, the HTTP server listens on `0.0.0.0`. To bind to a specific interface, set `BIND_ADDRESS` to an IP address (e.g. `127.0.0.1` for localhost only):
```bash
-e BIND_ADDRESS=127.0.0.1 \
```
**Disable External IP Lookup** (Privacy / air-gapped)
By default, the web UI displays your external IP address by querying external services. For privacy reasons, you can disable this feature using the `DISABLE_EXTERNAL_IP_LOOKUP` environment variable:
```bash
-e DISABLE_EXTERNAL_IP_LOOKUP=true \
```
When set, the "Your ext. IP:" display will be completely hidden and no external IP lookup requests will be made.
**Disable version update check** (Privacy / air-gapped)
On page load, the footer can check the latest release on GitHub to show "Latest" or "Update available". To disable this external request (e.g. in air-gapped or privacy-sensitive environments), set `UPDATE_CHECK=false`:
```bash
-e UPDATE_CHECK=false \
```
When disabled, the footer still shows the current version but does not perform any request to GitHub.
**OIDC Authentication Configuration (Optional)** **OIDC Authentication Configuration (Optional)**
Enable OIDC authentication by setting the required environment variables. This protects the web UI with your identity provider. The logout flow automatically redirects back to the login page after successful provider logout. Enable OIDC authentication by setting the required environment variables. This protects the web UI with your identity provider. The logout flow automatically redirects back to the login page after successful provider logout.
@@ -426,7 +380,7 @@ podman run -d \
-e OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback -e OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
``` ```
**Advanced Options:** **Advanced OIDC Options:**
```bash ```bash
-e OIDC_SCOPES=openid,profile,email,groups \ -e OIDC_SCOPES=openid,profile,email,groups \
-e OIDC_SESSION_MAX_AGE=7200 \ -e OIDC_SESSION_MAX_AGE=7200 \
@@ -436,26 +390,15 @@ podman run -d \
**Note:** If `OIDC_SESSION_SECRET` is not provided, a random secret will be generated on startup. For production, it's recommended to set a fixed secret. **Note:** If `OIDC_SESSION_SECRET` is not provided, a random secret will be generated on startup. For production, it's recommended to set a fixed secret.
Access the web interface at `http://localhost:3080`.
**Disable External IP Lookup** (Privacy) **Email alert template style**
By default, the web UI displays your external IP address by querying external services. For privacy reasons, you can disable this feature using the `DISABLE_EXTERNAL_IP_LOOKUP` environment variable: Alert emails (ban/unban notifications) use a "modern" HTML template by default. To use the classic style fail2ban-UI instead, set:
```bash ```bash
podman run -d \ -e emailStyle=classic
--name fail2ban-ui \
--network=host \
-e DISABLE_EXTERNAL_IP_LOOKUP=true \
-v /opt/podman-fail2ban-ui:/config:Z \
-v /etc/fail2ban:/etc/fail2ban:Z \
-v /var/log:/var/log:ro \
-v /var/run/fail2ban:/var/run/fail2ban \
swissmakers/fail2ban-ui:latest
``` ```
When set, the "Your ext. IP:" display will be completely hidden and no external IP lookup requests will be made.
**Volume Mounts Explained** **Volume Mounts Explained**
| Volume | Required | Purpose | | Volume | Required | Purpose |
@@ -505,8 +448,8 @@ See the [Security Notes](#-security-notes) section for complete OIDC configurati
### First Launch ### First Launch
1. **Access the Web Interface** 1. **Access the Web Interface**
- Navigate to `http://localhost:8080` (or your configured port) - Navigate to `http://localhost:8080` or `http://YOUR-LAN-IP:8080`
- Default port: `8080` (configurable via `PORT` environment variable or in UI settings) - Default port: `8080` (can be changed via `PORT` environment variable or in UI settings)
2. **Add Your First Server** 2. **Add Your First Server**
- **Local Server**: Enable the local connector if Fail2Ban runs on the same host - **Local Server**: Enable the local connector if Fail2Ban runs on the same host
@@ -521,6 +464,141 @@ See the [Security Notes](#-security-notes) section for complete OIDC configurati
## 📚 Documentation ## 📚 Documentation
### Highlevel System Architecture / Communication
- Backend: Go 1.24+ (Golang)
- Frontend: Vanilla JavaScript, Tailwind CSS
- Database: SQLite (embedded)
- Container Runtime: Podman/Docker compatible
- Service Management: systemd / container
- Security: SELinux compliant / least privileges
The following diagrams should describe the communication paths between browser (frontend) ↔ API, WebSocket message types, callbacks from Fail2ban instances, and connector types.
#### Browser (Frontend) ↔ Backend (HTTP / WebSocket) communication
```
┌───────────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND (Vanilla JS + Tailwind CSS) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Dashboard │ │ Filter Debug│ │ Settings │ │ (index) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ └────────────────┴────────────────┴────────────────┘ │
│ │ │
│ Communication to backend: │ HTTP/HTTPS (REST) │
│ • GET / │ • All /api/* (except callbacks) use your │
│ • GET /api/summary │ session when OIDC is enabled │
│ • GET /api/events/bans │ • X-F2B-Server header for server selection │
│ • GET /api/version │ │
│ • POST /api/jails/:jail/unban/:ip │ WebSocket: GET /api/ws (upgrade) │
│ • POST /api/jails/:jail/ban/:ip │ • Same origin, same cookies as HTTP │◀-┐
│ • POST /api/settings │ • Receives: heartbeat, console_log, │ │
│ • … (see diagram 2) │ ban_event, unban_event │ │
└────────────────────────────────────┼──────────────────────────────────────────────┘ │ W
│ │ e
▼ │ b
┌─────────────────────────────────────────────────────────────────────────────────┐ │ s
│ GO BACKEND (Gin) │ │ o
│ ┌───────────────────────────────────────────────────────────────────────────┐ │ │ c
│ │ PUBLIC (no OIDC-auth session needed for access): │ │ │ k
│ │ • /auth/login | /auth/callback | /auth/logout │ │ │ e
│ │ • /auth/status | /auth/user │ │ │ t
│ │ • POST /api/ban | POST /api/unban ← Fail2ban callbacks (a valid Callback │ │ │
│ │ • GET /api/ws (WebSocket) Secret is needed) │ │ │
│ │ • /static/* | /locales/* │ │----┘
│ └───────────────────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────────────────────┐ │
│ │ PROTECTED (when OIDC enabled): GET / | GET and POST to all other /api/* │ │
│ └───────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
#### Backend internals: API routes, WebSocket hub, storage, connectors
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ GIN SERVER │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ REST API (group /api) │ │
│ │ • GET /summary → Connector(s) → Fail2ban(jails, banned IPs) │ │
│ │ • GET /jails/:jail/config • POST /jails/:jail/config │ │
│ │ • GET /jails/manage • POST /jails/manage | POST /jails │ │
│ │ • POST /jails/:jail/unban/:ip • POST /jails/:jail/ban/:ip │ │
│ │ • GET /settings • POST /settings │ │
│ │ • GET /events/bans • GET /events/bans/stats | /insights │ │
│ │ • GET /version (optional GitHub request if UPDATE_CHECK) │ │
│ │ • GET /servers | POST/DELETE /servers | POST /servers/:id/test │ │
│ │ • GET /filters/* • POST /filters/test | POST/DELETE /filters │ │
│ │ • POST /fail2ban/restart • GET/POST /advanced-actions/* │ │
│ │ • POST /ban (callback) • POST /unban (callback) │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────┴──────────────────────────────────────────┐ │
│ │ WebSocket Hub (GET /api/ws) │ │
│ │ • register / unregister clients │ │
│ │ • broadcast to all clients: │ │
│ │ - type: "heartbeat" (every ~30s) │ │
│ │ - type: "console_log" (debug console lines) │ │
│ │ - type: "ban_event" (after POST /api/ban → store → broadcast) │ │
│ │ - type: "unban_event" (after POST /api/unban → store → broadcast) │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ SQLite Storage │ │ Whois / GeoIP │ │
│ │ • ban_events │ │ • IP → country/hostname │ │
│ │ • app_settings, servers │ │ MaxMind or ip-api.com │ │
│ │ • permanent_blocks │ │ • Used in UI and emails │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ Connector Manager │ │ Integrations + Email │ │
│ │ • Local (fail2ban.sock) │ │ • Mikrotik / pfSense / │ │
│ │ • SSH (exec on remote) │ │ OPNsense (block/unblock)│ │
│ │ • Agent (HTTP to agent) │ │ • SMTP alert emails │ │
│ │ • New server init: ensure │ └────────────────────────────┘ │
│ │ action.d (ui-custom- │ │
│ │ action.conf) │ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
```
#### Fail2ban instances → Fail2ban-UI (callbacks) and Fail2ban-UI → Fail2ban (via connectors)
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ FAIL2BAN INSTANCES (one per server: local, SSH host, or agent host) │
│ On each host: Fail2ban + action script (ui-custom-action.conf) │
│ On ban/unban → action runs → HTTP POST to Fail2ban-UI callback URL │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Outbound to Fail2ban-UI (from each Fail2ban host) │ │
│ │ POST <CallbackURL>/api/ban or /api/unban │ │
│ │ Header: X-Callback-Secret: <configured secret> │ │
│ │ Body: JSON { serverId, ip, jail, hostname, failures, logs } │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Fail2ban-UI Backend │ │
│ │ 1. Validate X-Callback-Secret → 401 if missing/invalid │ │
│ │ 2. Resolve server (serverId or hostname) │ │
│ │ 3. Whois/GeoIP enrichment │ │
│ │ 4. Store event in SQLite DB (ban_events) if nothing was invalid │ │
│ │ 5. Broadcast current event to WebSocket clients (ban_event / unban_event) │ │
│ │ 6. Optional: send SMTP alert │ │
│ │ 7 Run additional actions (e.g. block on pfSense for recurring offenders) │ │
│ │ 8. Respond status 200 OK - if all above was without an error │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ INBOUND from Fail2ban-UI to Fail2ban (per connector type) │
│ • Local: fail2ban-client over Unix socket (/var/run/fail2ban/fail2ban.sock) │
│ • SSH: SSH + fail2ban-client on remote host │
│ • Agent: HTTP to agent API (e.g. /v1/jails/:jail/unban, /v1/jails/:jail/ban) │
│ Used for: summary (jails, banned IPs), unban/ban from UI, config read/write, │
│ filter test, jail create/delete, restart/reload, logpath test │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### Deployment Guides ### Deployment Guides
- **[Container Deployment Guide](./deployment/container/README.md)**: - **[Container Deployment Guide](./deployment/container/README.md)**:
@@ -615,6 +693,7 @@ The **Fail2Ban Callback URL** is a critical setting that determines how Fail2Ban
**Privacy Settings** **Privacy Settings**
- **External IP Lookup**: By default, the web UI displays your external IP address. To disable this feature for privacy reasons, set the `DISABLE_EXTERNAL_IP_LOOKUP` environment variable to `true` or `1`. This will hide the "Your ext. IP:" display and prevent any external IP lookup requests. - **External IP Lookup**: By default, the web UI displays your external IP address. To disable this feature for privacy reasons, set the `DISABLE_EXTERNAL_IP_LOOKUP` environment variable to `true` or `1`. This will hide the "Your ext. IP:" display and prevent any external IP lookup requests.
- **Version update check**: On page load, the footer may request the latest release from GitHub to show an "Update available" badge. Set `UPDATE_CHECK=false` to disable this external request (e.g. air-gapped or privacy-sensitive environments). The current version is still shown in the footer.
- For custom callback URLs (e.g., reverse proxy or custom IP), you must manually update them to match your setup - For custom callback URLs (e.g., reverse proxy or custom IP), you must manually update them to match your setup
**Important Notes:** **Important Notes:**
@@ -721,6 +800,7 @@ OIDC_USERNAME_CLAIM=preferred_username # Claim to use as username (default:
OIDC_LOGOUT_URL=https://auth.example.com/logout # Provider logout URL (optional, auto-constructed if not set) OIDC_LOGOUT_URL=https://auth.example.com/logout # Provider logout URL (optional, auto-constructed if not set)
OIDC_CLIENT_SECRET_FILE=/path/to/secret-file # Path to client secret file (for auto-configuration) OIDC_CLIENT_SECRET_FILE=/path/to/secret-file # Path to client secret file (for auto-configuration)
OIDC_SKIP_VERIFY=false # Skip TLS verification (dev only, default: false) OIDC_SKIP_VERIFY=false # Skip TLS verification (dev only, default: false)
OIDC_SKIP_LOGINPAGE=false # Skip login page and redirect directly to OIDC provider (default: false)
``` ```
**Configuration Examples:** **Configuration Examples:**

View File

@@ -53,6 +53,9 @@ services:
# Optional: Disable external IP lookup for privacy (default: false). # Optional: Disable external IP lookup for privacy (default: false).
# When set to true, the "Your ext. IP:" display will be hidden and no external IP lookup requests will be made. # When set to true, the "Your ext. IP:" display will be hidden and no external IP lookup requests will be made.
# - DISABLE_EXTERNAL_IP_LOOKUP=true # - DISABLE_EXTERNAL_IP_LOOKUP=true
# Optional: Disable version update check (default: enabled).
# When set to false, the footer will not request the latest release from GitHub (e.g. air-gapped or privacy-sensitive environments).
# - UPDATE_CHECK=false
# ============================================ # ============================================
# OIDC Authentication (Optional) # OIDC Authentication (Optional)

View File

@@ -34,6 +34,9 @@ services:
# Optional: Disable external IP lookup for privacy (default: false). # Optional: Disable external IP lookup for privacy (default: false).
# When set to true, the "Your ext. IP:" display will be hidden and no external IP lookup requests will be made. # When set to true, the "Your ext. IP:" display will be hidden and no external IP lookup requests will be made.
# - DISABLE_EXTERNAL_IP_LOOKUP=true # - DISABLE_EXTERNAL_IP_LOOKUP=true
# Optional: Disable version update check (default: enabled).
# When set to false, the footer will not request the latest release from GitHub (e.g. air-gapped or privacy-sensitive environments).
# - UPDATE_CHECK=false
# ============================================ # ============================================
# OIDC Authentication (Optional) # OIDC Authentication (Optional)

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "Noch keine Serverdaten verfügbar.", "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_filtered_empty": "Keine gespeicherten Ereignisse passen zu den Filtern.",
"logs.overview.recent_count_label": "Angezeigte Ereignisse", "logs.overview.recent_count_label": "Angezeigte Ereignisse",
"logs.overview.load_more": "Mehr laden",
"logs.overview.country_unknown": "Unbekannt", "logs.overview.country_unknown": "Unbekannt",
"logs.overview.last_seen": "Zuletzt gesehen", "logs.overview.last_seen": "Zuletzt gesehen",
"logs.table.server": "Server", "logs.table.server": "Server",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "No keni Serverdate verfügbar.", "logs.overview.per_server_empty": "No keni Serverdate verfügbar.",
"logs.overview.recent_filtered_empty": "Kei Ereigniss erfülle d Filter.", "logs.overview.recent_filtered_empty": "Kei Ereigniss erfülle d Filter.",
"logs.overview.recent_count_label": "Aazeigti Ereigniss", "logs.overview.recent_count_label": "Aazeigti Ereigniss",
"logs.overview.load_more": "Mehr lade",
"logs.overview.country_unknown": "Unbekannt", "logs.overview.country_unknown": "Unbekannt",
"logs.overview.last_seen": "Zletscht gseh", "logs.overview.last_seen": "Zletscht gseh",
"logs.table.server": "Server", "logs.table.server": "Server",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "No per-server data available yet.", "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_filtered_empty": "No stored events match the current filters.",
"logs.overview.recent_count_label": "Events shown", "logs.overview.recent_count_label": "Events shown",
"logs.overview.load_more": "Load more",
"logs.overview.country_unknown": "Unknown", "logs.overview.country_unknown": "Unknown",
"logs.overview.last_seen": "Last seen", "logs.overview.last_seen": "Last seen",
"logs.table.server": "Server", "logs.table.server": "Server",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "Aún no hay datos por servidor.", "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_filtered_empty": "No hay eventos que coincidan con los filtros.",
"logs.overview.recent_count_label": "Eventos mostrados", "logs.overview.recent_count_label": "Eventos mostrados",
"logs.overview.load_more": "Cargar más",
"logs.overview.country_unknown": "Desconocido", "logs.overview.country_unknown": "Desconocido",
"logs.overview.last_seen": "Última vez", "logs.overview.last_seen": "Última vez",
"logs.table.server": "Servidor", "logs.table.server": "Servidor",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "Aucune donnée par serveur pour le moment.", "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_filtered_empty": "Aucun événement ne correspond aux filtres.",
"logs.overview.recent_count_label": "Événements affichés", "logs.overview.recent_count_label": "Événements affichés",
"logs.overview.load_more": "Charger plus",
"logs.overview.country_unknown": "Inconnu", "logs.overview.country_unknown": "Inconnu",
"logs.overview.last_seen": "Dernière apparition", "logs.overview.last_seen": "Dernière apparition",
"logs.table.server": "Serveur", "logs.table.server": "Serveur",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "Ancora nessun dato per server.", "logs.overview.per_server_empty": "Ancora nessun dato per server.",
"logs.overview.recent_filtered_empty": "Nessun evento corrisponde ai filtri.", "logs.overview.recent_filtered_empty": "Nessun evento corrisponde ai filtri.",
"logs.overview.recent_count_label": "Eventi mostrati", "logs.overview.recent_count_label": "Eventi mostrati",
"logs.overview.load_more": "Carica altri",
"logs.overview.country_unknown": "Sconosciuto", "logs.overview.country_unknown": "Sconosciuto",
"logs.overview.last_seen": "Ultima visualizzazione", "logs.overview.last_seen": "Ultima visualizzazione",
"logs.table.server": "Server", "logs.table.server": "Server",

View File

@@ -603,6 +603,139 @@ WHERE 1=1`
return results, rows.Err() return results, rows.Err()
} }
const (
// MaxBanEventsLimit is the maximum number of events per API request (pagination page size).
MaxBanEventsLimit = 50
// MaxBanEventsOffset is the maximum offset (total events loaded in UI capped for browser stability).
MaxBanEventsOffset = 1000
)
// ListBanEventsFiltered returns ban events with optional search and country filter, ordered by occurred_at DESC.
// search is applied as LIKE %search% on ip, jail, server_name, hostname, country.
// limit is capped at MaxBanEventsLimit; offset is capped at MaxBanEventsOffset.
func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset int, since time.Time, search, country string) ([]BanEventRecord, error) {
if db == nil {
return nil, errors.New("storage not initialised")
}
if limit <= 0 || limit > MaxBanEventsLimit {
limit = MaxBanEventsLimit
}
if offset < 0 || offset > MaxBanEventsOffset {
offset = 0
}
baseQuery := `
SELECT id, server_id, server_name, jail, ip, country, hostname, failures, whois, logs, event_type, occurred_at, created_at
FROM ban_events
WHERE 1=1`
args := []any{}
if serverID != "" {
baseQuery += " AND server_id = ?"
args = append(args, serverID)
}
if !since.IsZero() {
baseQuery += " AND occurred_at >= ?"
args = append(args, since.UTC())
}
search = strings.TrimSpace(search)
if search != "" {
baseQuery += " AND (ip LIKE ? OR jail LIKE ? OR server_name LIKE ? OR COALESCE(hostname,'') LIKE ? OR COALESCE(country,'') LIKE ?)"
pattern := "%" + search + "%"
for i := 0; i < 5; i++ {
args = append(args, pattern)
}
}
if country != "" && country != "all" {
if country == "__unknown__" {
baseQuery += " AND (country IS NULL OR country = '')"
} else {
baseQuery += " AND LOWER(COALESCE(country,'')) = ?"
args = append(args, strings.ToLower(country))
}
}
baseQuery += " ORDER BY occurred_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []BanEventRecord
for rows.Next() {
var rec BanEventRecord
var eventType sql.NullString
if err := rows.Scan(
&rec.ID,
&rec.ServerID,
&rec.ServerName,
&rec.Jail,
&rec.IP,
&rec.Country,
&rec.Hostname,
&rec.Failures,
&rec.Whois,
&rec.Logs,
&eventType,
&rec.OccurredAt,
&rec.CreatedAt,
); err != nil {
return nil, err
}
if eventType.Valid {
rec.EventType = eventType.String
} else {
rec.EventType = "ban"
}
results = append(results, rec)
}
return results, rows.Err()
}
// CountBanEventsFiltered returns the total count of ban events matching the same filters as ListBanEventsFiltered.
func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) {
if db == nil {
return 0, errors.New("storage not initialised")
}
query := `SELECT COUNT(*) 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())
}
search = strings.TrimSpace(search)
if search != "" {
query += " AND (ip LIKE ? OR jail LIKE ? OR server_name LIKE ? OR COALESCE(hostname,'') LIKE ? OR COALESCE(country,'') LIKE ?)"
pattern := "%" + search + "%"
for i := 0; i < 5; i++ {
args = append(args, pattern)
}
}
if country != "" && country != "all" {
if country == "__unknown__" {
query += " AND (country IS NULL OR country = '')"
} else {
query += " AND LOWER(COALESCE(country,'')) = ?"
args = append(args, strings.ToLower(country))
}
}
var total int64
if err := db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
// CountBanEventsByServer returns simple aggregation per server. // CountBanEventsByServer returns simple aggregation per server.
func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) { func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
if db == nil { if db == nil {
@@ -918,6 +1051,7 @@ CREATE TABLE IF NOT EXISTS ban_events (
CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id); CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id);
CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at); CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at);
CREATE INDEX IF NOT EXISTS idx_ban_events_ip ON ban_events(ip);
CREATE TABLE IF NOT EXISTS permanent_blocks ( CREATE TABLE IF NOT EXISTS permanent_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -365,13 +365,25 @@ func UnbanNotificationHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Unban notification processed successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Unban notification processed successfully"})
} }
// ListBanEventsHandler returns stored ban events from the internal database. // ListBanEventsHandler returns stored ban events from the internal database with optional search and pagination.
// Query params: serverId, limit (default 50, max 50), offset (default 0, max 1000), since, search, country.
// When offset=0, response includes total (matching count). Response includes hasMore when more results exist.
func ListBanEventsHandler(c *gin.Context) { func ListBanEventsHandler(c *gin.Context) {
serverID := c.Query("serverId") serverID := c.Query("serverId")
limit := 100 limit := storage.MaxBanEventsLimit
if limitStr := c.DefaultQuery("limit", "100"); limitStr != "" { if limitStr := c.DefaultQuery("limit", strconv.Itoa(storage.MaxBanEventsLimit)); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed if parsed <= storage.MaxBanEventsLimit {
limit = parsed
}
}
}
offset := 0
if offsetStr := c.DefaultQuery("offset", "0"); offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
if parsed <= storage.MaxBanEventsOffset {
offset = parsed
}
} }
} }
@@ -381,13 +393,24 @@ func ListBanEventsHandler(c *gin.Context) {
since = parsed since = parsed
} }
} }
search := strings.TrimSpace(c.Query("search"))
country := strings.TrimSpace(c.Query("country"))
events, err := storage.ListBanEvents(c.Request.Context(), serverID, limit, since) ctx := c.Request.Context()
events, err := storage.ListBanEventsFiltered(ctx, serverID, limit, offset, since, search, country)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"events": events})
resp := gin.H{"events": events, "hasMore": len(events) == limit}
if offset == 0 {
total, errCount := storage.CountBanEventsFiltered(ctx, serverID, since, search, country)
if errCount == nil {
resp["total"] = total
}
}
c.JSON(http.StatusOK, resp)
} }
// BanStatisticsHandler returns aggregated ban counts per server. // BanStatisticsHandler returns aggregated ban counts per server.

View File

@@ -69,61 +69,93 @@ function fetchBanStatisticsData() {
}); });
} }
function fetchBanEventsData() { // Builds query string for ban events API: limit, offset, search, country, serverId
return fetch('/api/events/bans?limit=200') function buildBanEventsQuery(offset, append) {
var params = [
'limit=' + BAN_EVENTS_PAGE_SIZE,
'offset=' + (append ? Math.min(latestBanEvents.length, BAN_EVENTS_MAX_LOADED) : 0)
];
var search = (banEventsFilterText || '').trim();
if (search) {
params.push('search=' + encodeURIComponent(search));
}
var country = (banEventsFilterCountry || 'all').trim();
if (country && country !== 'all') {
params.push('country=' + encodeURIComponent(country));
}
if (currentServerId) {
params.push('serverId=' + encodeURIComponent(currentServerId));
}
return '/api/events/bans?' + params.join('&');
}
// options: { append: true } to load next page and append; otherwise fetches first page (reset).
function fetchBanEventsData(options) {
options = options || {};
var append = options.append === true;
var offset = append ? Math.min(latestBanEvents.length, BAN_EVENTS_MAX_LOADED) : 0;
if (append && offset >= BAN_EVENTS_MAX_LOADED) {
return Promise.resolve();
}
var url = buildBanEventsQuery(offset, append);
return fetch(url)
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
latestBanEvents = data && data.events ? data.events : []; var events = data && data.events ? data.events : [];
// Track the last event ID to prevent duplicates from WebSocket if (append) {
if (latestBanEvents.length > 0 && wsManager) { latestBanEvents = latestBanEvents.concat(events);
} else {
latestBanEvents = events;
}
banEventsHasMore = data.hasMore === true;
if (offset === 0 && typeof data.total === 'number') {
banEventsTotal = data.total;
}
if (!append && latestBanEvents.length > 0 && wsManager) {
wsManager.lastBanEventId = latestBanEvents[0].id; wsManager.lastBanEventId = latestBanEvents[0].id;
} }
}) })
.catch(function(err) { .catch(function(err) {
console.error('Error fetching ban events:', err); console.error('Error fetching ban events:', err);
latestBanEvents = latestBanEvents || []; if (!append) {
latestBanEvents = latestBanEvents || [];
banEventsTotal = null;
banEventsHasMore = false;
}
}); });
} }
// Add new ban or unban event from WebSocket // Add new ban or unban event from WebSocket (only when not searching; cap at BAN_EVENTS_MAX_LOADED)
function addBanEventFromWebSocket(event) { function addBanEventFromWebSocket(event) {
// Check if event already exists (prevent duplicates) var hasSearch = (banEventsFilterText || '').trim().length > 0;
// Only check by ID if both events have IDs if (hasSearch) {
// When user is searching, list is from API; don't prepend to avoid inconsistency
if (typeof showBanEventToast === 'function') {
showBanEventToast(event);
}
refreshDashboardData();
return;
}
var exists = false; var exists = false;
if (event.id) { if (event.id) {
exists = latestBanEvents.some(function(e) { exists = latestBanEvents.some(function(e) { return e.id === event.id; });
return e.id === event.id;
});
} else { } else {
// If no ID, check by IP, jail, eventType, and occurredAt timestamp
exists = latestBanEvents.some(function(e) { exists = latestBanEvents.some(function(e) {
return e.ip === event.ip && return e.ip === event.ip && e.jail === event.jail && e.eventType === event.eventType && e.occurredAt === event.occurredAt;
e.jail === event.jail &&
e.eventType === event.eventType &&
e.occurredAt === event.occurredAt;
}); });
} }
if (!exists) { if (!exists) {
// Ensure eventType is set (default to 'ban' for backward compatibility)
if (!event.eventType) { if (!event.eventType) {
event.eventType = 'ban'; event.eventType = 'ban';
} }
console.log('Adding new event from WebSocket:', event); console.log('Adding new event from WebSocket:', event);
// Prepend to the beginning of the array
latestBanEvents.unshift(event); latestBanEvents.unshift(event);
// Keep only the last 200 events if (latestBanEvents.length > BAN_EVENTS_MAX_LOADED) {
if (latestBanEvents.length > 200) { latestBanEvents = latestBanEvents.slice(0, BAN_EVENTS_MAX_LOADED);
latestBanEvents = latestBanEvents.slice(0, 200);
} }
// Show toast notification first
if (typeof showBanEventToast === 'function') { if (typeof showBanEventToast === 'function') {
showBanEventToast(event); showBanEventToast(event);
} }
// Refresh dashboard data (summary, stats, insights) and re-render
refreshDashboardData(); refreshDashboardData();
} else { } else {
console.log('Skipping duplicate event:', event); console.log('Skipping duplicate event:', event);
@@ -238,60 +270,38 @@ function getBanEventCountries() {
}); });
} }
function getFilteredBanEvents() { // Debounced refetch of ban events from API (search/country) and re-render only the log overview (no full dashboard = no scroll jump)
var text = (banEventsFilterText || '').toLowerCase(); function scheduleBanEventsRefetch() {
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 scheduleLogOverviewRender() {
if (banEventsFilterDebounce) { if (banEventsFilterDebounce) {
clearTimeout(banEventsFilterDebounce); clearTimeout(banEventsFilterDebounce);
} }
banEventsFilterDebounce = setTimeout(function() { banEventsFilterDebounce = setTimeout(function() {
renderLogOverviewSection();
banEventsFilterDebounce = null; banEventsFilterDebounce = null;
}, 100); fetchBanEventsData().then(function() {
renderLogOverviewSection();
});
}, 300);
} }
function updateBanEventsSearch(value) { function updateBanEventsSearch(value) {
banEventsFilterText = value || ''; banEventsFilterText = value || '';
scheduleLogOverviewRender(); scheduleBanEventsRefetch();
} }
function updateBanEventsCountry(value) { function updateBanEventsCountry(value) {
banEventsFilterCountry = value || 'all'; banEventsFilterCountry = value || 'all';
scheduleLogOverviewRender(); fetchBanEventsData().then(function() {
renderLogOverviewSection();
});
}
function loadMoreBanEvents() {
if (latestBanEvents.length >= BAN_EVENTS_MAX_LOADED || !banEventsHasMore) {
return;
}
fetchBanEventsData({ append: true }).then(function() {
renderLogOverviewSection();
});
} }
function getRecurringIPMap() { function getRecurringIPMap() {
@@ -839,58 +849,58 @@ function renderLogOverviewContent() {
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>';
// Always show search bar and table (like Search Banned IPs) so user can clear search when no matches
var countries = getBanEventCountries();
var recurringMap = getRecurringIPMap();
var searchQuery = (banEventsFilterText || '').trim();
var totalLabel = banEventsTotal != null ? banEventsTotal : '—';
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') + ': ' + latestBanEvents.length + ' / ' + totalLabel + '</p>';
html += ''
+ '<div class="overflow-x-auto">'
+ ' <table class="min-w-full divide-y divide-gray-200 text-sm">'
+ ' <thead class="bg-gray-50">'
+ ' <tr>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.time">Time</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.server">Server</th>'
+ ' <th class="hidden sm:table-cell px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.jail">Jail</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.ip">IP</th>'
+ ' <th class="hidden md:table-cell px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.country">Country</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.actions">Actions</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody class="bg-white divide-y divide-gray-200">';
if (!latestBanEvents.length) { if (!latestBanEvents.length) {
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_empty">No stored events found.</p>'; var hasFilter = (banEventsFilterText || '').trim().length > 0 || ((banEventsFilterCountry || 'all').trim() !== 'all');
var emptyMsgKey = hasFilter ? 'logs.overview.recent_filtered_empty' : 'logs.overview.recent_empty';
html += '<tr><td colspan="6" class="px-2 py-4 text-center text-gray-500" data-i18n="' + emptyMsgKey + '"></td></tr>';
} else { } else {
var countries = getBanEventCountries(); latestBanEvents.forEach(function(event, index) {
var filteredEvents = getFilteredBanEvents();
var recurringMap = getRecurringIPMap();
var searchQuery = (banEventsFilterText || '').trim();
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 {
html += ''
+ '<div class="overflow-x-auto">'
+ ' <table class="min-w-full divide-y divide-gray-200 text-sm">'
+ ' <thead class="bg-gray-50">'
+ ' <tr>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.time">Time</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.server">Server</th>'
+ ' <th class="hidden sm:table-cell px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.jail">Jail</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.ip">IP</th>'
+ ' <th class="hidden md:table-cell px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.country">Country</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.actions">Actions</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody class="bg-white divide-y divide-gray-200">';
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 serverValue = event.serverName || event.serverId || ''; var serverValue = event.serverName || event.serverId || '';
@@ -924,8 +934,14 @@ function renderLogOverviewContent() {
+ ' </td>' + ' </td>'
+ ' </tr>'; + ' </tr>';
}); });
html += ' </tbody></table></div>'; }
}
html += ' </tbody></table></div>';
if (banEventsHasMore && latestBanEvents.length > 0 && latestBanEvents.length < BAN_EVENTS_MAX_LOADED) {
var loadMoreLabel = typeof t === 'function' ? t('logs.overview.load_more', 'Load more') : 'Load more';
html += '<div class="mt-3 text-center">'
+ '<button type="button" class="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500" onclick="loadMoreBanEvents()">' + loadMoreLabel + '</button>'
+ '</div>';
} }
html += '</div>'; html += '</div>';

View File

@@ -9,6 +9,8 @@ var latestSummary = null;
var latestSummaryError = null; var latestSummaryError = null;
var latestBanStats = {}; var latestBanStats = {};
var latestBanEvents = []; var latestBanEvents = [];
var banEventsTotal = null;
var banEventsHasMore = false;
var latestBanInsights = { var latestBanInsights = {
totals: { overall: 0, today: 0, week: 0 }, totals: { overall: 0, today: 0, week: 0 },
countries: [], countries: [],
@@ -18,6 +20,8 @@ var latestServerInsights = null;
var banEventsFilterText = ''; var banEventsFilterText = '';
var banEventsFilterCountry = 'all'; var banEventsFilterCountry = 'all';
var banEventsFilterDebounce = null; var banEventsFilterDebounce = null;
var BAN_EVENTS_PAGE_SIZE = 50;
var BAN_EVENTS_MAX_LOADED = 1000;
var translations = {}; var translations = {};
var sshKeysCache = null; var sshKeysCache = null;
var openModalCount = 0; var openModalCount = 0;