24 Commits

Author SHA1 Message Date
c03a5c1cbc backup 2026-04-05 21:46:25 +02:00
eb983799cd chore: release v0.9.18 2026-04-05 21:45:28 +02:00
d3f564d66f Remove inline map count labels and hide archive source badges 2026-04-05 20:59:28 +02:00
bbd57c808b chore: release v0.9.17 2026-04-05 20:44:43 +02:00
ffaa14f864 Switch dashboard/map/history views to archive source for long-term data
Update fail2ban dbpurgeage to 648000 and history sync backfill/pagination for archive-based 7.5 day history.
2026-04-05 20:21:54 +02:00
7d09b78437 chore: release v0.9.16 2026-04-05 18:54:02 +02:00
8e2bb5d3fb Migrate WorldMap to d3-geo, fix TopoJSON country ID mappings and update tests 2026-04-05 18:50:44 +02:00
bfe0daf754 Fix WorldMap hover highlight by memoizing style objects and handlers
Memoize per-Geography style objects with useMemo so React.memo can
skip re-renders when only the tooltip position changes. Stabilize
mouse event handlers with useCallback using data-* attributes instead
of per-Geography closures. This eliminates the state-update race
condition that caused hover fill colors to flash back to defaults.
2026-04-01 14:53:38 +02:00
13823b1182 fix(history): unify History filter bar with Jail and IP inputs 2026-04-01 09:37:38 +02:00
7967191ccd backup 2026-03-29 21:24:12 +02:00
470c29443c chore: release v0.9.15 2026-03-29 21:21:30 +02:00
6f15e1fa24 fix(map): add test-only useMapData exports for MapPage mock 2026-03-29 21:20:54 +02:00
487cb171f2 fix(history): auto-apply history filters and remove explicit buttons
HistoryPage no longer requires Apply/Clear; filter state auto-syncs with query. Added guard to avoid redundant state updates. Updated task list in Docs/Tasks.md to mark completion.
2026-03-29 20:35:11 +02:00
7789353690 Add MapPage pagination and page-size selector; update Web-Design docs 2026-03-29 15:23:47 +02:00
ccfcbc82c5 backup 2026-03-29 15:01:30 +02:00
7626c9cb60 Fix WorldMap hover tooltip/role behavior and mark task done 2026-03-29 15:01:10 +02:00
ac4fd967aa Fix update_jail_config to ignore backend field 2026-03-28 12:55:32 +01:00
9f05da2d4d Complete history archive support for dashboard/map data and mark task finished
Add source=archive option for dashboard endpoints and history service; update Docs/Tasks.md; include archive branch for list_bans, bans_by_country, ban_trend, bans_by_jail; tests for archive paths.
2026-03-28 12:39:47 +01:00
876af46955 history archive router precedence + endpoint/source tests + history sync register test + task status update 2026-03-24 21:06:58 +01:00
0d4a2a3311 history archive purge uses current age and test uses dynamic timestamps 2026-03-24 20:52:40 +01:00
f555b1b0a2 Add server dbpurgeage warning state in API and mark task complete 2026-03-24 20:45:07 +01:00
a30b92471a chore: persist docs and frontend lockfile updates 2026-03-24 20:20:35 +01:00
9e43282bbc fix(config): stabilize config hook callbacks to prevent action/filter flicker 2026-03-24 20:13:23 +01:00
2ea4a8304f backup 2026-03-24 19:46:12 +01:00
57 changed files with 2130 additions and 951 deletions

View File

@@ -1 +1 @@
v0.9.14
v0.9.18

View File

@@ -78,6 +78,11 @@ Chains steps 13 automatically with appropriate sleep intervals.
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
(see `fail2ban/paths-lsio.conf``remote_logs_path = /remotelogs`).
BanGUI also extends fail2ban history retention for archive backfill. In
the development config `fail2ban/fail2ban.conf` the database purge age is
set to `648000` seconds (7.5 days) so the first archive sync can recover a
full 7-day window before fail2ban purges old rows.
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
```ini

View File

@@ -68,6 +68,15 @@ FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
echo "frontend/package.json version updated → ${FRONT_VERSION}"
# Keep backend/pyproject.toml in sync so app.__version__ matches Docker/VERSION in the runtime container.
BACKEND_PYPROJECT="${SCRIPT_DIR}/../backend/pyproject.toml"
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
echo "backend/pyproject.toml version updated → ${FRONT_VERSION}"
else
echo "Warning: backend/pyproject.toml not found, skipping backend version sync" >&2
fi
# ---------------------------------------------------------------------------
# Push containers
# ---------------------------------------------------------------------------

View File

@@ -52,6 +52,8 @@ The main landing page after login. Shows recent ban activity at a glance.
- Last 7 days (week)
- Last 30 days (month)
- Last 365 days (year)
- **Data source selection:** The "Last 24 hours" preset queries fail2ban's live database directly for real-time accuracy. All longer presets (7 days, 30 days, 365 days) query the BanGUI long-term archive, because fail2ban's own database only retains the last 24 hours by default.
- A **data-source badge** next to the time-range selector indicates whether the current view is showing **Live (fail2ban DB)** or **Archive (BanGUI DB)** data.
---
@@ -70,14 +72,16 @@ A geographical overview of ban activity.
- Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend)
- The color threshold values are configurable through the application settings
- **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner.
- For every country that has bans, the total count is displayed centred inside that country's borders in the selected time range.
- Countries with zero banned IPs show no number and no label — they remain blank and transparent.
- For every country that has bans, the total count is shown only in the country tooltip, not rendered on the map itself.
- Countries with zero banned IPs show no tooltip and remain blank and transparent.
- Clicking a country filters the companion table below to show only bans from that country.
- Time-range selector with the same quick presets:
- Last 24 hours
- Last 7 days
- Last 30 days
- Last 365 days
- **Data source selection:** Same rule as the Dashboard — "Last 24 hours" uses the live fail2ban database; all other ranges use the BanGUI archive.
- A **data-source badge** is displayed alongside the time-range selector indicating **Live (fail2ban DB)** or **Archive (BanGUI DB)**.
---
@@ -245,13 +249,15 @@ A page to inspect and modify the fail2ban configuration without leaving the web
## 7. Ban History
A view for exploring historical ban data stored in the fail2ban database.
A view for exploring historical ban data stored in the BanGUI long-term archive.
### History Table
- Browse all past bans across all jails, not just the currently active ones.
- **Columns:** Time of ban, IP address, jail, ban duration, ban count (how many times this IP was banned), country.
- Filter by jail, by IP address, or by time range.
- The default time range on first load is **Last 7 days** and the data source is always the **BanGUI archive**, ensuring the full retention window is visible regardless of fail2ban's `dbpurgeage` setting.
- A **data-source badge** is displayed indicating **Archive (BanGUI DB)**.
- See at a glance which IPs are repeat offenders (high ban count).
### Per-IP History
@@ -259,6 +265,17 @@ A view for exploring historical ban data stored in the fail2ban database.
- Select any IP to see its full ban timeline: every ban event, which jail triggered it, when it started, and how long it lasted.
- Merged view showing total failures and matched log lines aggregated across all bans for that IP.
### Persistent Historical Archive
- BanGUI stores a separate long-term historical ban archive in its own application database, independent from fail2ban's database retention settings.
- On each configured sync cycle (default every 5 minutes), BanGUI reads latest entries from fail2ban `bans` table and appends any new events to BanGUI history storage.
- Supports both `ban` and `unban` events; audit record includes: `timestamp`, `ip`, `jail`, `action`, `duration`, `origin` (manual, auto, blocklist, etc.), `failures`, `matches`, and optional `country` / `ASN` enrichment.
- Includes incremental import logic with dedupe: using unique constraint on (ip, jail, action, timeofban) to prevent duplication across sync cycles.
- Provides backfill mode for initial startup: import the last 7.5 days of existing fail2ban history into BanGUI to avoid dark gaps after restart. Requires fail2ban's `dbpurgeage` to be set to at least `648000` (7.5 days) — BanGUI ships with this value pre-configured in its Docker setup.
- Includes configurable archive purge policy in BanGUI (default 365 days), separate from fail2ban `dbpurgeage`, to keep app storage bounded while preserving audit data.
- Expose API endpoints for querying persistent history, with filters for timeframe, jail, origin, IP, and current ban status.
- On fail2ban connectivity failure, BanGUI continues serving historical data; next successful sync resumes ingestion without data loss.
---
## 8. External Blocklist Importer

View File

@@ -7,3 +7,72 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
---
## Open Issues
### Backend Architecture
- **Replace the single shared SQLite connection.**
- Current startup code opens one `aiosqlite.Connection` and reuses it for every request.
- This should be replaced with either a connection pool or request-scoped connections to avoid concurrency and locking issues.
- Update request dependencies, application lifecycle, and tests to use the new pattern.
- **Refactor dependency wiring and shared resource management.**
- Remove hidden module-level import coupling between routers, services, and shared utilities.
- Introduce explicit factories or providers for shared resources such as DB, HTTP client session, scheduler, and settings.
- Ensure routers depend on injected providers rather than global state or dynamic imports.
- **Harden fail2ban integration.**
- Remove the `sys.path` hack that locates `fail2ban-master` at runtime.
- Replace it with a deterministic packaging or configuration model so the backend does not depend on repository layout.
- Refactor `Fail2BanClient` so concurrency control is instance-based and not backed by hidden module globals.
- **Improve startup / setup guard behavior.**
- Convert `SetupRedirectMiddleware` from an on-demand DB check into a startup/initialisation guard where possible.
- Cache setup completion in a safe way and provide an explicit invalidation path if the application state changes.
- Reduce middleware responsibility and avoid DB access during normal request dispatch.
- **Make deployment configuration explicit.**
- Move hard-coded environment assumptions such as CORS origins into settings.
- Ensure `fail2ban_socket`, `fail2ban_config_dir`, and startup commands are fully configurable via `Settings`.
- Document production-ready defaults separately from development defaults.
### Reliability and Resilience
- **Add backend lifecycle tests for resource cleanup.**
- Verify startup opens and initialises DB, HTTP session, scheduler, and geo cache correctly.
- Verify shutdown closes those resources cleanly.
- **Add concurrency/regression coverage for DB and fail2ban socket use.**
- Add tests that simulate multiple concurrent requests using the same DB dependency.
- Add tests around fail2ban socket retries, protocol errors, and rate limiting.
- **Improve state caching and invalidation.**
- Add tests for session cache invalidation on logout.
- Add tests for setup completion caching so stale state is never served.
### Backend Feature Work
- **Document and implement backend-safe environment-driven CORS.**
- Add support for production and local development origins through configuration.
- Avoid a hardcoded Vite origin in the core app factory.
- **Centralise scheduler job registration.**
- Refactor APScheduler registration so background tasks are registered through a common lifecycle helper.
- Ensure jobs can be discovered, replaced, and tested without requiring implicit `app.state` side effects.
- **Strengthen fail2ban error handling and reporting.**
- Standardise `502` responses for connection/protocol failures across all endpoints.
- Add structured logging for retries and fatal socket failures.
- Ensure the UI can distinguish offline fail2ban from internal backend failures.
- **Improve documentation of backend responsibilities.**
- Keep `Docs/Tasks.md` aligned with the backend architecture review.
- Add references to the backend modules, resource lifecycle, and dependency model in the documentation.
### Priority Execution Plan
1. Fix the global SQLite connection pattern and tests.
2. Refactor dependency injection / explicit shared resources.
3. Harden fail2ban client concurrency and packaging.
4. Convert setup guard to a safer startup-driven model.
5. Add deployment-safe configuration and production-ready CORS.
6. Add lifecycle and concurrency regression tests.

View File

@@ -210,7 +210,7 @@ Use Fluent UI React components as the building blocks. The following mapping sho
| Element | Fluent component | Notes |
|---|---|---|
| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. |
| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. Use clear pagination controls (page number + prev/next) and a page-size selector (25/50/100) for large result sets. |
| Stat cards | `DocumentCard` or custom `Stack` card | Dashboard status bar — server status, total bans, active jails. Use `Depth 4`. |
| Status indicators | `Badge` / `Icon` + colour | Server online/offline, jail running/stopped/idle. |
| Country labels | Monospaced text + flag emoji or icon | Geo data next to IP addresses. |

View File

@@ -1,224 +0,0 @@
# Config File Service Extraction Summary
## ✓ Extraction Complete
Three new service modules have been created by extracting functions from `config_file_service.py`.
### Files Created
| File | Lines | Status |
|------|-------|--------|
| [jail_config_service.py](jail_config_service.py) | 991 | ✓ Created |
| [filter_config_service.py](filter_config_service.py) | 765 | ✓ Created |
| [action_config_service.py](action_config_service.py) | 988 | ✓ Created |
| **Total** | **2,744** | **✓ Verified** |
---
## 1. JAIL_CONFIG Service (`jail_config_service.py`)
### Public Functions (7)
- `list_inactive_jails(config_dir, socket_path)` → InactiveJailListResponse
- `activate_jail(config_dir, socket_path, name, req)` → JailActivationResponse
- `deactivate_jail(config_dir, socket_path, name)` → JailActivationResponse
- `delete_jail_local_override(config_dir, socket_path, name)` → None
- `validate_jail_config(config_dir, name)` → JailValidationResult
- `rollback_jail(config_dir, socket_path, name, start_cmd_parts)` → RollbackResponse
- `_rollback_activation_async(config_dir, name, socket_path, original_content)` → bool
### Helper Functions (5)
- `_write_local_override_sync()` - Atomic write of jail.d/{name}.local
- `_restore_local_file_sync()` - Restore or delete .local file during rollback
- `_validate_regex_patterns()` - Validate failregex/ignoreregex patterns
- `_set_jail_local_key_sync()` - Update single key in jail section
- `_validate_jail_config_sync()` - Synchronous validation (filter/action files, patterns, logpath)
### Custom Exceptions (3)
- `JailNotFoundInConfigError`
- `JailAlreadyActiveError`
- `JailAlreadyInactiveError`
### Shared Dependencies Imported
- `_safe_jail_name()` - From config_file_service
- `_parse_jails_sync()` - From config_file_service
- `_build_inactive_jail()` - From config_file_service
- `_get_active_jail_names()` - From config_file_service
- `_probe_fail2ban_running()` - From config_file_service
- `wait_for_fail2ban()` - From config_file_service
- `start_daemon()` - From config_file_service
- `_resolve_filter()` - From config_file_service
- `_parse_multiline()` - From config_file_service
- `_SOCKET_TIMEOUT`, `_META_SECTIONS` - Constants
---
## 2. FILTER_CONFIG Service (`filter_config_service.py`)
### Public Functions (6)
- `list_filters(config_dir, socket_path)` → FilterListResponse
- `get_filter(config_dir, socket_path, name)` → FilterConfig
- `update_filter(config_dir, socket_path, name, req, do_reload=False)` → FilterConfig
- `create_filter(config_dir, socket_path, req, do_reload=False)` → FilterConfig
- `delete_filter(config_dir, name)` → None
- `assign_filter_to_jail(config_dir, socket_path, jail_name, req, do_reload=False)` → None
### Helper Functions (4)
- `_extract_filter_base_name(filter_raw)` - Extract base name from filter string
- `_build_filter_to_jails_map()` - Map filters to jails using them
- `_parse_filters_sync()` - Scan filter.d/ and return tuples
- `_write_filter_local_sync()` - Atomic write of filter.d/{name}.local
- `_validate_regex_patterns()` - Validate regex patterns (shared with jail_config)
### Custom Exceptions (5)
- `FilterNotFoundError`
- `FilterAlreadyExistsError`
- `FilterReadonlyError`
- `FilterInvalidRegexError`
- `FilterNameError` (re-exported from config_file_service)
### Shared Dependencies Imported
- `_safe_filter_name()` - From config_file_service
- `_safe_jail_name()` - From config_file_service
- `_parse_jails_sync()` - From config_file_service
- `_get_active_jail_names()` - From config_file_service
- `_resolve_filter()` - From config_file_service
- `_parse_multiline()` - From config_file_service
- `_SAFE_FILTER_NAME_RE` - Constant pattern
---
## 3. ACTION_CONFIG Service (`action_config_service.py`)
### Public Functions (7)
- `list_actions(config_dir, socket_path)` → ActionListResponse
- `get_action(config_dir, socket_path, name)` → ActionConfig
- `update_action(config_dir, socket_path, name, req, do_reload=False)` → ActionConfig
- `create_action(config_dir, socket_path, req, do_reload=False)` → ActionConfig
- `delete_action(config_dir, name)` → None
- `assign_action_to_jail(config_dir, socket_path, jail_name, req, do_reload=False)` → None
- `remove_action_from_jail(config_dir, socket_path, jail_name, action_name, do_reload=False)` → None
### Helper Functions (5)
- `_safe_action_name(name)` - Validate action name
- `_extract_action_base_name()` - Extract base name from action string
- `_build_action_to_jails_map()` - Map actions to jails using them
- `_parse_actions_sync()` - Scan action.d/ and return tuples
- `_append_jail_action_sync()` - Append action to jail.d/{name}.local
- `_remove_jail_action_sync()` - Remove action from jail.d/{name}.local
- `_write_action_local_sync()` - Atomic write of action.d/{name}.local
### Custom Exceptions (4)
- `ActionNotFoundError`
- `ActionAlreadyExistsError`
- `ActionReadonlyError`
- `ActionNameError`
### Shared Dependencies Imported
- `_safe_jail_name()` - From config_file_service
- `_parse_jails_sync()` - From config_file_service
- `_get_active_jail_names()` - From config_file_service
- `_build_parser()` - From config_file_service
- `_SAFE_ACTION_NAME_RE` - Constant pattern
---
## 4. SHARED Utilities (remain in `config_file_service.py`)
### Utility Functions (14)
- `_safe_jail_name(name)` → str
- `_safe_filter_name(name)` → str
- `_ordered_config_files(config_dir)` → list[Path]
- `_build_parser()` → configparser.RawConfigParser
- `_is_truthy(value)` → bool
- `_parse_int_safe(value)` → int | None
- `_parse_time_to_seconds(value, default)` → int
- `_parse_multiline(raw)` → list[str]
- `_resolve_filter(raw_filter, jail_name, mode)` → str
- `_parse_jails_sync(config_dir)` → tuple
- `_build_inactive_jail(name, settings, source_file, config_dir=None)` → InactiveJail
- `_get_active_jail_names(socket_path)` → set[str]
- `_probe_fail2ban_running(socket_path)` → bool
- `wait_for_fail2ban(socket_path, max_wait_seconds, poll_interval)` → bool
- `start_daemon(start_cmd_parts)` → bool
### Shared Exceptions (3)
- `JailNameError`
- `FilterNameError`
- `ConfigWriteError`
### Constants (7)
- `_SOCKET_TIMEOUT`
- `_SAFE_JAIL_NAME_RE`
- `_META_SECTIONS`
- `_TRUE_VALUES`
- `_FALSE_VALUES`
---
## Import Dependencies
### jail_config_service imports:
```python
config_file_service: (shared utilities + private functions)
jail_service.reload_all()
Fail2BanConnectionError
```
### filter_config_service imports:
```python
config_file_service: (shared utilities + _set_jail_local_key_sync)
jail_service.reload_all()
conffile_parser: (parse/merge/serialize filter functions)
jail_config_service: (JailNotFoundInConfigError - lazy import)
```
### action_config_service imports:
```python
config_file_service: (shared utilities + _build_parser)
jail_service.reload_all()
conffile_parser: (parse/merge/serialize action functions)
jail_config_service: (JailNotFoundInConfigError - lazy import)
```
---
## Cross-Service Dependencies
**Circular imports handled via lazy imports:**
- `filter_config_service` imports `JailNotFoundInConfigError` from `jail_config_service` inside function
- `action_config_service` imports `JailNotFoundInConfigError` from `jail_config_service` inside function
**Shared functions re-used:**
- `_set_jail_local_key_sync()` exported from `jail_config_service`, used by `filter_config_service`
- `_append_jail_action_sync()` and `_remove_jail_action_sync()` internal to `action_config_service`
---
## Verification Results
**Syntax Check:** All three files compile without errors
**Import Verification:** All imports resolved correctly
**Total Lines:** 2,744 lines across three new files
**Function Coverage:** 100% of specified functions extracted
**Type Hints:** Preserved throughout
**Docstrings:** All preserved with full documentation
**Comments:** All inline comments preserved
---
## Next Steps (if needed)
1. **Update router imports** - Point from config_file_service to specific service modules:
- `jail_config_service` for jail operations
- `filter_config_service` for filter operations
- `action_config_service` for action operations
2. **Update config_file_service.py** - Remove all extracted functions (optional cleanup)
- Optionally keep it as a facade/aggregator
- Or reduce it to only the shared utilities module
3. **Add __all__ exports** to each new module for cleaner public API
4. **Update type hints** in models if needed for cross-service usage
5. **Testing** - Run existing tests to ensure no regressions

View File

@@ -75,6 +75,20 @@ CREATE TABLE IF NOT EXISTS geo_cache (
);
"""
_CREATE_HISTORY_ARCHIVE: str = """
CREATE TABLE IF NOT EXISTS history_archive (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jail TEXT NOT NULL,
ip TEXT NOT NULL,
timeofban INTEGER NOT NULL,
bancount INTEGER NOT NULL,
data TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('ban', 'unban')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(ip, jail, action, timeofban)
);
"""
# Ordered list of DDL statements to execute on initialisation.
_SCHEMA_STATEMENTS: list[str] = [
_CREATE_SETTINGS,
@@ -83,6 +97,7 @@ _SCHEMA_STATEMENTS: list[str] = [
_CREATE_BLOCKLIST_SOURCES,
_CREATE_IMPORT_LOG,
_CREATE_GEO_CACHE,
_CREATE_HISTORY_ARCHIVE,
]

View File

@@ -48,7 +48,7 @@ from app.routers import (
server,
setup,
)
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check, history_sync
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
from app.utils.jail_config import ensure_jail_configs
@@ -183,6 +183,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# --- Periodic re-resolve of NULL-country geo entries ---
geo_re_resolve.register(app)
# --- Periodic history sync from fail2ban into BanGUI archive ---
history_sync.register(app)
log.info("bangui_started")
try:

View File

@@ -56,3 +56,7 @@ class ServerSettingsResponse(BaseModel):
model_config = ConfigDict(strict=True)
settings: ServerSettings
warnings: dict[str, bool] = Field(
default_factory=dict,
description="Warnings highlighting potentially unsafe settings.",
)

View File

@@ -0,0 +1,148 @@
"""Ban history archive repository.
Provides persistence APIs for the BanGUI archival history table in the
application database.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from app.models.ban import BLOCKLIST_JAIL, BanOrigin
if TYPE_CHECKING:
import aiosqlite
async def archive_ban_event(
db: aiosqlite.Connection,
jail: str,
ip: str,
timeofban: int,
bancount: int,
data: str,
action: str = "ban",
) -> bool:
"""Insert a new archived ban/unban event, ignoring duplicates."""
async with db.execute(
"""INSERT OR IGNORE INTO history_archive
(jail, ip, timeofban, bancount, data, action)
VALUES (?, ?, ?, ?, ?, ?)""",
(jail, ip, timeofban, bancount, data, action),
) as cursor:
inserted = cursor.rowcount == 1
await db.commit()
return inserted
async def get_archived_history(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[dict], int]:
"""Return a paginated archived history result set."""
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
offset = (page - 1) * page_size
async with db.execute(f"SELECT COUNT(*) FROM history_archive {where_sql}", params) as cur:
row = await cur.fetchone()
total = int(row[0]) if row is not None and row[0] is not None else 0
async with db.execute(
"SELECT jail, ip, timeofban, bancount, data, action "
"FROM history_archive "
f"{where_sql} "
"ORDER BY timeofban DESC LIMIT ? OFFSET ?",
[*params, page_size, offset],
) as cur:
rows = await cur.fetchall()
records = [
{
"jail": str(r[0]),
"ip": str(r[1]),
"timeofban": int(r[2]),
"bancount": int(r[3]),
"data": str(r[4]),
"action": str(r[5]),
}
for r in rows
]
return records, total
async def get_all_archived_history(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict]:
"""Return all archived history rows for the given filters."""
page: int = 1
page_size: int = 500
all_rows: list[dict] = []
while True:
rows, total = await get_archived_history(
db=db,
since=since,
jail=jail,
ip_filter=ip_filter,
origin=origin,
action=action,
page=page,
page_size=page_size,
)
all_rows.extend(rows)
if len(rows) < page_size:
break
page += 1
return all_rows
async def purge_archived_history(db: aiosqlite.Connection, age_seconds: int) -> int:
"""Purge archived entries older than *age_seconds*; return rows deleted."""
threshold = int(datetime.datetime.now(datetime.UTC).timestamp()) - age_seconds
async with db.execute(
"DELETE FROM history_archive WHERE timeofban < ?",
(threshold,),
) as cursor:
deleted = cursor.rowcount
await db.commit()
return deleted

View File

@@ -12,7 +12,7 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
import aiohttp
@@ -83,6 +83,7 @@ async def get_dashboard_bans(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: Literal["fail2ban", "archive"] = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
origin: BanOrigin | None = Query(
@@ -117,10 +118,11 @@ async def get_dashboard_bans(
return await ban_service.list_bans(
socket_path,
range,
source=source,
page=page,
page_size=page_size,
http_session=http_session,
app_db=None,
app_db=request.app.state.db,
geo_batch_lookup=geo_service.lookup_batch,
origin=origin,
)
@@ -135,6 +137,7 @@ async def get_bans_by_country(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: Literal["fail2ban", "archive"] = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -164,10 +167,11 @@ async def get_bans_by_country(
return await ban_service.bans_by_country(
socket_path,
range,
source=source,
http_session=http_session,
geo_cache_lookup=geo_service.lookup_cached_only,
geo_batch_lookup=geo_service.lookup_batch,
app_db=None,
app_db=request.app.state.db,
origin=origin,
)
@@ -181,6 +185,7 @@ async def get_ban_trend(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: Literal["fail2ban", "archive"] = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -212,7 +217,13 @@ async def get_ban_trend(
"""
socket_path: str = request.app.state.settings.fail2ban_socket
return await ban_service.ban_trend(socket_path, range, origin=origin)
return await ban_service.ban_trend(
socket_path,
range,
source=source,
app_db=request.app.state.db,
origin=origin,
)
@router.get(
@@ -224,6 +235,7 @@ async def get_bans_by_jail(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: Literal["fail2ban", "archive"] = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -248,4 +260,10 @@ async def get_bans_by_jail(
"""
socket_path: str = request.app.state.settings.fail2ban_socket
return await ban_service.bans_by_jail(socket_path, range, origin=origin)
return await ban_service.bans_by_jail(
socket_path,
range,
source=source,
app_db=request.app.state.db,
origin=origin,
)

View File

@@ -15,7 +15,7 @@ Routes
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
import aiohttp
@@ -56,6 +56,10 @@ async def get_history(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
),
source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.",
),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(
default=_DEFAULT_PAGE_SIZE,
@@ -94,9 +98,47 @@ async def get_history(
jail=jail,
ip_filter=ip,
origin=origin,
source=source,
page=page,
page_size=page_size,
geo_enricher=_enricher,
db=request.app.state.db,
)
@router.get(
"/archive",
response_model=HistoryListResponse,
summary="Return a paginated list of archived historical bans",
)
async def get_history_archive(
request: Request,
_auth: AuthDep,
range: TimeRange | None = Query(
default=None,
description="Optional time-range filter. Omit for all-time.",
),
jail: str | None = Query(default=None, description="Restrict results to this jail name."),
ip: str | None = Query(default=None, description="Restrict results to IPs matching this prefix."),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."),
) -> HistoryListResponse:
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
return await geo_service.lookup(addr, http_session)
return await history_service.list_history(
socket_path,
range_=range,
jail=jail,
ip_filter=ip,
source="archive",
page=page,
page_size=page_size,
geo_enricher=_enricher,
db=request.app.state.db,
)

View File

@@ -112,6 +112,7 @@ async def list_bans(
socket_path: str,
range_: TimeRange,
*,
source: str = "fail2ban",
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
http_session: aiohttp.ClientSession | None = None,
@@ -160,8 +161,25 @@ async def list_bans(
since: int = _since_unix(range_)
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
offset: int = (page - 1) * effective_page_size
origin_clause, origin_params = _origin_sql_filter(origin)
if source not in ("fail2ban", "archive"):
raise ValueError(f"Unsupported source: {source!r}")
if source == "archive":
if app_db is None:
raise ValueError("app_db must be provided when source is 'archive'")
from app.repositories.history_archive_repo import get_archived_history
rows, total = await get_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
page=page,
page_size=effective_page_size,
)
else:
db_path: str = await get_fail2ban_db_path(socket_path)
log.info(
"ban_service_list_bans",
@@ -192,11 +210,19 @@ async def list_bans(
items: list[DashboardBanItem] = []
for row in rows:
jail: str = row.jail
ip: str = row.ip
banned_at: str = ts_to_iso(row.timeofban)
ban_count: int = row.bancount
if source == "archive":
jail = str(row["jail"])
ip = str(row["ip"])
banned_at = ts_to_iso(int(row["timeofban"]))
ban_count = int(row["bancount"])
matches, _ = parse_data_json(row["data"])
else:
jail = row.jail
ip = row.ip
banned_at = ts_to_iso(row.timeofban)
ban_count = row.bancount
matches, _ = parse_data_json(row.data)
service: str | None = matches[0] if matches else None
country_code: str | None = None
@@ -256,6 +282,8 @@ _MAX_COMPANION_BANS: int = 200
async def bans_by_country(
socket_path: str,
range_: TimeRange,
*,
source: str = "fail2ban",
http_session: aiohttp.ClientSession | None = None,
geo_cache_lookup: GeoCacheLookup | None = None,
geo_batch_lookup: GeoBatchLookup | None = None,
@@ -300,6 +328,45 @@ async def bans_by_country(
"""
since: int = _since_unix(range_)
if source not in ("fail2ban", "archive"):
raise ValueError(f"Unsupported source: {source!r}")
if source == "archive":
if app_db is None:
raise ValueError("app_db must be provided when source is 'archive'")
from app.repositories.history_archive_repo import (
get_all_archived_history,
get_archived_history,
)
all_rows = await get_all_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
)
total = len(all_rows)
# companion rows for the table should be most recent
companion_rows, _ = await get_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
page=1,
page_size=_MAX_COMPANION_BANS,
)
agg_rows = {}
for row in all_rows:
ip = str(row["ip"])
agg_rows[ip] = agg_rows.get(ip, 0) + 1
unique_ips = list(agg_rows.keys())
else:
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await get_fail2ban_db_path(socket_path)
log.info(
@@ -334,7 +401,7 @@ async def bans_by_country(
offset=0,
)
unique_ips: list[str] = [r.ip for r in agg_rows]
unique_ips = [r.ip for r in agg_rows]
geo_map: dict[str, GeoInfo] = {}
if http_session is not None and unique_ips and geo_cache_lookup is not None:
@@ -371,12 +438,28 @@ async def bans_by_country(
countries: dict[str, int] = {}
country_names: dict[str, str] = {}
for agg_row in agg_rows:
ip: str = agg_row.ip
if source == "archive":
agg_items = [
{
"ip": ip,
"event_count": count,
}
for ip, count in agg_rows.items()
]
else:
agg_items = agg_rows
for agg_row in agg_items:
if source == "archive":
ip = agg_row["ip"]
event_count = agg_row["event_count"]
else:
ip = agg_row.ip
event_count = agg_row.event_count
geo = geo_map.get(ip)
cc: str | None = geo.country_code if geo else None
cn: str | None = geo.country_name if geo else None
event_count: int = agg_row.event_count
if cc:
countries[cc] = countries.get(cc, 0) + event_count
@@ -386,26 +469,38 @@ async def bans_by_country(
# Build companion table from recent rows (geo already cached from batch step).
bans: list[DashboardBanItem] = []
for companion_row in companion_rows:
if source == "archive":
ip = companion_row["ip"]
jail = companion_row["jail"]
banned_at = ts_to_iso(int(companion_row["timeofban"]))
ban_count = int(companion_row["bancount"])
service = None
else:
ip = companion_row.ip
jail = companion_row.jail
banned_at = ts_to_iso(companion_row.timeofban)
ban_count = companion_row.bancount
matches, _ = parse_data_json(companion_row.data)
service = matches[0] if matches else None
geo = geo_map.get(ip)
cc = geo.country_code if geo else None
cn = geo.country_name if geo else None
asn: str | None = geo.asn if geo else None
org: str | None = geo.org if geo else None
matches, _ = parse_data_json(companion_row.data)
bans.append(
DashboardBanItem(
ip=ip,
jail=companion_row.jail,
banned_at=ts_to_iso(companion_row.timeofban),
service=matches[0] if matches else None,
jail=jail,
banned_at=banned_at,
service=service,
country_code=cc,
country_name=cn,
asn=asn,
org=org,
ban_count=companion_row.bancount,
origin=_derive_origin(companion_row.jail),
ban_count=ban_count,
origin=_derive_origin(jail),
)
)
@@ -426,6 +521,8 @@ async def ban_trend(
socket_path: str,
range_: TimeRange,
*,
source: str = "fail2ban",
app_db: aiosqlite.Connection | None = None,
origin: BanOrigin | None = None,
) -> BanTrendResponse:
"""Return ban counts aggregated into equal-width time buckets.
@@ -457,8 +554,40 @@ async def ban_trend(
since: int = _since_unix(range_)
bucket_secs: int = BUCKET_SECONDS[range_]
num_buckets: int = bucket_count(range_)
origin_clause, origin_params = _origin_sql_filter(origin)
if source not in ("fail2ban", "archive"):
raise ValueError(f"Unsupported source: {source!r}")
if source == "archive":
if app_db is None:
raise ValueError("app_db must be provided when source is 'archive'")
from app.repositories.history_archive_repo import get_all_archived_history
all_rows = await get_all_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
)
counts: list[int] = [0] * num_buckets
for row in all_rows:
timeofban = int(row["timeofban"])
bucket_index = int((timeofban - since) / bucket_secs)
if 0 <= bucket_index < num_buckets:
counts[bucket_index] += 1
log.info(
"ban_service_ban_trend",
source=source,
since=since,
range=range_,
origin=origin,
bucket_secs=bucket_secs,
num_buckets=num_buckets,
)
else:
db_path: str = await get_fail2ban_db_path(socket_path)
log.info(
"ban_service_ban_trend",
@@ -501,6 +630,8 @@ async def bans_by_jail(
socket_path: str,
range_: TimeRange,
*,
source: str = "fail2ban",
app_db: aiosqlite.Connection | None = None,
origin: BanOrigin | None = None,
) -> BansByJailResponse:
"""Return ban counts aggregated per jail for the selected time window.
@@ -522,6 +653,43 @@ async def bans_by_jail(
sorted descending and the total ban count.
"""
since: int = _since_unix(range_)
if source not in ("fail2ban", "archive"):
raise ValueError(f"Unsupported source: {source!r}")
if source == "archive":
if app_db is None:
raise ValueError("app_db must be provided when source is 'archive'")
from app.repositories.history_archive_repo import get_all_archived_history
all_rows = await get_all_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
)
jail_counter: dict[str, int] = {}
for row in all_rows:
jail_name = str(row["jail"])
jail_counter[jail_name] = jail_counter.get(jail_name, 0) + 1
total = sum(jail_counter.values())
jail_counts = [
JailBanCountModel(jail=jail_name, count=count)
for jail_name, count in sorted(jail_counter.items(), key=lambda x: x[1], reverse=True)
]
log.debug(
"ban_service_bans_by_jail",
source=source,
since=since,
since_iso=ts_to_iso(since),
range=range_,
origin=origin,
)
else:
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await get_fail2ban_db_path(socket_path)

View File

@@ -351,8 +351,8 @@ async def update_jail_config(
await _set("datepattern", update.date_pattern)
if update.dns_mode is not None:
await _set("usedns", update.dns_mode)
if update.backend is not None:
await _set("backend", update.backend)
# backend is managed by fail2ban and cannot be changed at runtime by API.
# This field is therefore ignored during updates.
if update.log_encoding is not None:
await _set("logencoding", update.log_encoding)
if update.prefregex is not None:

View File

@@ -16,6 +16,8 @@ from typing import TYPE_CHECKING
import structlog
if TYPE_CHECKING:
import aiosqlite
from app.models.geo import GeoEnricher
from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange
@@ -63,9 +65,11 @@ async def list_history(
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
source: str = "fail2ban",
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: GeoEnricher | None = None,
db: aiosqlite.Connection | None = None,
) -> HistoryListResponse:
"""Return a paginated list of historical ban records with optional filters.
@@ -104,6 +108,63 @@ async def list_history(
page=page,
)
items: list[HistoryBanItem] = []
total: int
if source == "archive":
if db is None:
raise ValueError("db must be provided when source is 'archive'")
from app.repositories.history_archive_repo import get_archived_history
archived_rows, total = await get_archived_history(
db=db,
since=since,
jail=jail,
ip_filter=ip_filter,
page=page,
page_size=effective_page_size,
)
for row in archived_rows:
jail_name = row["jail"]
ip = row["ip"]
banned_at = ts_to_iso(int(row["timeofban"]))
ban_count = int(row["bancount"])
matches, failures = parse_data_json(row["data"])
# archive records may include actions; we treat all as history
country_code = None
country_name = None
asn = None
org = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
if geo is not None:
country_code = geo.country_code
country_name = geo.country_name
asn = geo.asn
org = geo.org
except Exception: # noqa: BLE001
log.warning("history_service_geo_lookup_failed", ip=ip)
items.append(
HistoryBanItem(
ip=ip,
jail=jail_name,
banned_at=banned_at,
ban_count=ban_count,
failures=failures,
matches=matches,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
)
)
else:
rows, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=since,
@@ -114,7 +175,6 @@ async def list_history(
page_size=effective_page_size,
)
items: list[HistoryBanItem] = []
for row in rows:
jail_name: str = row.jail
ip: str = row.ip

View File

@@ -160,8 +160,12 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
db_max_matches=db_max_matches,
)
log.info("server_settings_fetched")
return ServerSettingsResponse(settings=settings)
warnings: dict[str, bool] = {
"db_purge_age_too_low": db_purge_age < 86400,
}
log.info("server_settings_fetched", db_purge_age=db_purge_age, warnings=warnings)
return ServerSettingsResponse(settings=settings, warnings=warnings)
async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> None:

View File

@@ -0,0 +1,109 @@
"""History sync background task.
Periodically copies new records from the fail2ban sqlite database into the
BanGUI application archive table to prevent gaps when fail2ban purges old rows.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
import structlog
from app.repositories import fail2ban_db_repo
from app.utils.fail2ban_db_utils import get_fail2ban_db_path
if TYPE_CHECKING: # pragma: no cover
from fastapi import FastAPI
log: structlog.stdlib.BoundLogger = structlog.get_logger()
#: Stable APScheduler job id.
JOB_ID: str = "history_sync"
#: Interval in seconds between sync runs.
HISTORY_SYNC_INTERVAL: int = 300
#: Backfill window when archive is empty (seconds).
BACKFILL_WINDOW: int = 648000
async def _get_last_archive_ts(db) -> int | None:
async with db.execute("SELECT MAX(timeofban) FROM history_archive") as cur:
row = await cur.fetchone()
if row is None or row[0] is None:
return None
return int(row[0])
async def _run_sync(app: FastAPI) -> None:
db = app.state.db
socket_path: str = app.state.settings.fail2ban_socket
try:
last_ts = await _get_last_archive_ts(db)
now_ts = int(datetime.datetime.now(datetime.UTC).timestamp())
if last_ts is None:
last_ts = now_ts - BACKFILL_WINDOW
log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW)
per_page = 500
next_since = last_ts + 1
total_synced = 0
while True:
fail2ban_db_path = await get_fail2ban_db_path(socket_path)
rows, total = await fail2ban_db_repo.get_history_page(
db_path=fail2ban_db_path,
since=next_since,
page=1,
page_size=per_page,
)
if not rows:
break
from app.repositories.history_archive_repo import archive_ban_event
for row in rows:
await archive_ban_event(
db=db,
jail=row.jail,
ip=row.ip,
timeofban=row.timeofban,
bancount=row.bancount,
data=row.data,
action="ban",
)
total_synced += 1
# Continue where we left off by max timeofban + 1.
max_time = max(row.timeofban for row in rows)
next_since = max_time + 1
if len(rows) < per_page:
break
log.info("history_sync_complete", synced=total_synced)
except Exception:
log.exception("history_sync_failed")
def register(app: FastAPI) -> None:
"""Register the history sync periodic job.
Should be called after scheduler startup, from the lifespan handler.
"""
app.state.scheduler.add_job(
_run_sync,
trigger="interval",
seconds=HISTORY_SYNC_INTERVAL,
kwargs={"app": app},
id=JOB_ID,
replace_existing=True,
next_run_time=datetime.datetime.now(tz=datetime.UTC),
)
log.info("history_sync_scheduled", interval_seconds=HISTORY_SYNC_INTERVAL)

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bangui-backend"
version = "0.9.8"
version = "0.9.18"
description = "BanGUI backend — fail2ban web management interface"
requires-python = ">=3.12"
dependencies = [

View File

@@ -0,0 +1,60 @@
"""Tests for history_archive_repo."""
from __future__ import annotations
import time
from pathlib import Path
import aiosqlite
import pytest
from app.db import init_db
from app.repositories.history_archive_repo import archive_ban_event, get_archived_history, purge_archived_history
@pytest.fixture
async def app_db(tmp_path: Path) -> str:
path = str(tmp_path / "app.db")
async with aiosqlite.connect(path) as db:
db.row_factory = aiosqlite.Row
await init_db(db)
return path
@pytest.mark.asyncio
async def test_archive_ban_event_deduplication(app_db: str) -> None:
async with aiosqlite.connect(app_db) as db:
# first insert should add
inserted = await archive_ban_event(db, "sshd", "1.1.1.1", 1000, 1, "{}", "ban")
assert inserted
# duplicate event is ignored
inserted = await archive_ban_event(db, "sshd", "1.1.1.1", 1000, 1, "{}", "ban")
assert not inserted
@pytest.mark.asyncio
async def test_get_archived_history_filtering_and_pagination(app_db: str) -> None:
async with aiosqlite.connect(app_db) as db:
await archive_ban_event(db, "sshd", "1.1.1.1", 1000, 1, "{}", "ban")
await archive_ban_event(db, "nginx", "2.2.2.2", 2000, 1, "{}", "ban")
rows, total = await get_archived_history(db, jail="sshd")
assert total == 1
assert rows[0]["ip"] == "1.1.1.1"
rows, total = await get_archived_history(db, page=1, page_size=1)
assert total == 2
assert len(rows) == 1
@pytest.mark.asyncio
async def test_purge_archived_history(app_db: str) -> None:
now = int(time.time())
async with aiosqlite.connect(app_db) as db:
await archive_ban_event(db, "sshd", "1.1.1.1", now - 3000, 1, "{}", "ban")
await archive_ban_event(db, "sshd", "1.1.1.2", now - 1000, 1, "{}", "ban")
deleted = await purge_archived_history(db, age_seconds=2000)
assert deleted == 1
rows, total = await get_archived_history(db)
assert total == 1

View File

@@ -290,6 +290,17 @@ class TestDashboardBans:
called_range = mock_list.call_args[0][1]
assert called_range == "7d"
async def test_accepts_source_param(
self, dashboard_client: AsyncClient
) -> None:
"""The ``source`` query parameter is forwarded to ban_service."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans?source=archive")
called_source = mock_list.call_args[1]["source"]
assert called_source == "archive"
async def test_empty_ban_list_returns_zero_total(
self, dashboard_client: AsyncClient
) -> None:
@@ -417,6 +428,15 @@ class TestBansByCountry:
called_range = mock_fn.call_args[0][1]
assert called_range == "7d"
async def test_invalid_source_returns_422(
self, dashboard_client: AsyncClient
) -> None:
"""An invalid source value returns HTTP 422."""
response = await dashboard_client.get(
"/api/dashboard/bans/by-country?source=invalid"
)
assert response.status_code == 422
async def test_empty_window_returns_empty_response(
self, dashboard_client: AsyncClient
) -> None:
@@ -492,6 +512,16 @@ class TestDashboardBansOriginField:
origins = {ban["origin"] for ban in bans}
assert origins == {"blocklist", "selfblock"}
async def test_bans_by_country_source_param_forwarded(
self, dashboard_client: AsyncClient
) -> None:
"""The ``source`` query parameter is forwarded to bans_by_country."""
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/by-country?source=archive")
assert mock_fn.call_args[1]["source"] == "archive"
async def test_blocklist_origin_serialised_correctly(
self, dashboard_client: AsyncClient
) -> None:
@@ -701,6 +731,15 @@ class TestBanTrend:
)
assert response.status_code == 422
async def test_invalid_source_returns_422(
self, dashboard_client: AsyncClient
) -> None:
"""An invalid source value returns HTTP 422."""
response = await dashboard_client.get(
"/api/dashboard/bans/trend?source=invalid"
)
assert response.status_code == 422
async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
"""Empty bucket list is serialised correctly."""
from app.models.ban import BanTrendResponse
@@ -836,6 +875,15 @@ class TestBansByJail:
)
assert response.status_code == 422
async def test_invalid_source_returns_422(
self, dashboard_client: AsyncClient
) -> None:
"""An invalid source value returns HTTP 422."""
response = await dashboard_client.get(
"/api/dashboard/bans/by-jail?source=invalid"
)
assert response.status_code == 422
async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None:
"""Empty jails list is serialised correctly."""
from app.models.ban import BansByJailResponse

View File

@@ -225,6 +225,32 @@ class TestHistoryList:
_args, kwargs = mock_fn.call_args
assert kwargs.get("origin") == "blocklist"
async def test_forwards_source_filter(self, history_client: AsyncClient) -> None:
"""The ``source`` query parameter is forwarded to the service."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?source=archive")
_args, kwargs = mock_fn.call_args
assert kwargs.get("source") == "archive"
async def test_archive_route_forces_source_archive(
self, history_client: AsyncClient
) -> None:
"""GET /api/history/archive should call list_history with source='archive'."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history/archive")
_args, kwargs = mock_fn.call_args
assert kwargs.get("source") == "archive"
async def test_empty_result(self, history_client: AsyncClient) -> None:
"""An empty history returns items=[] and total=0."""
with patch(

View File

@@ -68,7 +68,8 @@ def _make_settings() -> ServerSettingsResponse:
db_path="/var/lib/fail2ban/fail2ban.sqlite3",
db_purge_age=86400,
db_max_matches=10,
)
),
warnings={"db_purge_age_too_low": False},
)
@@ -93,6 +94,7 @@ class TestGetServerSettings:
data = resp.json()
assert data["settings"]["log_level"] == "INFO"
assert data["settings"]["db_purge_age"] == 86400
assert data["warnings"]["db_purge_age_too_low"] is False
async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None:
"""GET /api/server/settings returns 401 without session."""

View File

@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch
import aiosqlite
import pytest
from app.db import init_db
from app.services import ban_service
# ---------------------------------------------------------------------------
@@ -143,6 +144,29 @@ async def empty_f2b_db_path(tmp_path: Path) -> str:
return path
@pytest.fixture
async def app_db_with_archive(tmp_path: Path) -> aiosqlite.Connection:
"""Return an app database connection pre-populated with archived ban rows."""
db_path = str(tmp_path / "app_archive.db")
db = await aiosqlite.connect(db_path)
db.row_factory = aiosqlite.Row
await init_db(db)
await db.execute(
"INSERT INTO history_archive (jail, ip, timeofban, bancount, data, action) VALUES (?, ?, ?, ?, ?, ?)",
("sshd", "1.2.3.4", _ONE_HOUR_AGO, 1, '{"matches": ["fail"], "failures": 1}', "ban"),
)
await db.execute(
"INSERT INTO history_archive (jail, ip, timeofban, bancount, data, action) VALUES (?, ?, ?, ?, ?, ?)",
("nginx", "5.6.7.8", _ONE_HOUR_AGO, 1, '{"matches": ["fail"], "failures": 2}', "ban"),
)
await db.commit()
yield db
await db.close()
# ---------------------------------------------------------------------------
# list_bans — happy path
# ---------------------------------------------------------------------------
@@ -233,6 +257,20 @@ class TestListBansHappyPath:
assert result.total == 3
async def test_source_archive_reads_from_archive(
self, app_db_with_archive: aiosqlite.Connection
) -> None:
"""Using source='archive' reads from the BanGUI archive table."""
result = await ban_service.list_bans(
"/fake/sock",
"24h",
source="archive",
app_db=app_db_with_archive,
)
assert result.total == 2
assert {item.ip for item in result.items} == {"1.2.3.4", "5.6.7.8"}
# ---------------------------------------------------------------------------
# list_bans — geo enrichment
@@ -616,6 +654,20 @@ class TestOriginFilter:
assert result.total == 3
async def test_bans_by_country_source_archive_reads_archive(
self, app_db_with_archive: aiosqlite.Connection
) -> None:
"""``bans_by_country`` accepts source='archive' and reads archived rows."""
result = await ban_service.bans_by_country(
"/fake/sock",
"24h",
source="archive",
app_db=app_db_with_archive,
)
assert result.total == 2
assert len(result.bans) == 2
# ---------------------------------------------------------------------------
# bans_by_country — background geo resolution (Task 3)
@@ -802,6 +854,19 @@ class TestBanTrend:
timestamps = [b.timestamp for b in result.buckets]
assert timestamps == sorted(timestamps)
async def test_ban_trend_source_archive_reads_archive(
self, app_db_with_archive: aiosqlite.Connection
) -> None:
"""``ban_trend`` accepts source='archive' and uses archived rows."""
result = await ban_service.ban_trend(
"/fake/sock",
"24h",
source="archive",
app_db=app_db_with_archive,
)
assert sum(b.count for b in result.buckets) == 2
async def test_bans_counted_in_correct_bucket(self, tmp_path: Path) -> None:
"""A ban at a known time appears in the expected bucket."""
import time as _time
@@ -1018,6 +1083,20 @@ class TestBansByJail:
assert result.total == 3
assert len(result.jails) == 3
async def test_bans_by_jail_source_archive_reads_archive(
self, app_db_with_archive: aiosqlite.Connection
) -> None:
"""``bans_by_jail`` accepts source='archive' and aggregates archived rows."""
result = await ban_service.bans_by_jail(
"/fake/sock",
"24h",
source="archive",
app_db=app_db_with_archive,
)
assert result.total == 2
assert any(j.jail == "sshd" for j in result.jails)
async def test_diagnostic_warning_when_zero_results_despite_data(
self, tmp_path: Path
) -> None:

View File

@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch
import aiosqlite
import pytest
from app.db import init_db
from app.services import history_service
# ---------------------------------------------------------------------------
@@ -264,6 +265,31 @@ class TestListHistory:
assert result.page == 1
assert result.page_size == 2
async def test_source_archive_reads_from_archive(self, f2b_db_path: str, tmp_path: Path) -> None:
"""Using source='archive' reads from the BanGUI archive table."""
app_db_path = str(tmp_path / "app_archive.db")
async with aiosqlite.connect(app_db_path) as db:
db.row_factory = aiosqlite.Row
await init_db(db)
await db.execute(
"INSERT INTO history_archive (jail, ip, timeofban, bancount, data, action) VALUES (?, ?, ?, ?, ?, ?)",
("sshd", "10.0.0.1", _ONE_HOUR_AGO, 1, '{"matches": [], "failures": 0}', "ban"),
)
await db.commit()
with patch(
"app.services.history_service.get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket",
source="archive",
db=db,
)
assert result.total == 1
assert result.items[0].ip == "10.0.0.1"
# ---------------------------------------------------------------------------
# get_ip_detail tests

View File

@@ -63,6 +63,16 @@ class TestGetSettings:
assert result.settings.log_target == "/var/log/fail2ban.log"
assert result.settings.db_purge_age == 86400
assert result.settings.db_max_matches == 10
assert result.warnings == {"db_purge_age_too_low": False}
async def test_db_purge_age_warning_when_below_minimum(self) -> None:
"""get_settings sets warning when db_purge_age is below 86400 seconds."""
responses = {**_DEFAULT_RESPONSES, "get|dbpurgeage": (0, 3600)}
with _patch_client(responses):
result = await server_service.get_settings(_SOCKET)
assert result.settings.db_purge_age == 3600
assert result.warnings == {"db_purge_age_too_low": True}
async def test_db_path_parsed(self) -> None:
"""get_settings returns the correct database file path."""

View File

@@ -0,0 +1,59 @@
"""Tests for history_sync task registration."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from app.tasks import history_sync
class TestHistorySyncTask:
async def test_register_schedules_job(self) -> None:
fake_scheduler = MagicMock()
class FakeState:
pass
class FakeSettings:
fail2ban_socket = "/tmp/fake.sock"
app = type("FakeApp", (), {})()
app.state = FakeState()
app.state.scheduler = fake_scheduler
app.state.settings = FakeSettings()
history_sync.register(app)
fake_scheduler.add_job.assert_called_once()
called_args, called_kwargs = fake_scheduler.add_job.call_args
assert called_kwargs["id"] == history_sync.JOB_ID
assert called_kwargs["kwargs"]["app"] == app
async def test_backfill_window_is_7_5_days(self) -> None:
assert history_sync.BACKFILL_WINDOW == 648000
async def test_sync_uses_strict_since_after_restart(self) -> None:
fake_app = type("FakeApp", (), {})()
fake_app.state = type("FakeState", (), {})()
fake_app.state.settings = type("FakeSettings", (), {})()
fake_app.state.settings.fail2ban_socket = "/tmp/fake.sock"
fake_app.state.db = MagicMock()
async def fake_get_history_page(*, db_path: str, since: int, page: int, page_size: int, **kwargs):
assert since == 1001
return [], 0
async def fake_get_fail2ban_db_path(socket_path: str) -> str:
return "/tmp/fake.sqlite3"
with patch(
"app.tasks.history_sync._get_last_archive_ts",
new=AsyncMock(return_value=1000),
), patch(
"app.tasks.history_sync.get_fail2ban_db_path",
new=fake_get_fail2ban_db_path,
), patch(
"app.tasks.history_sync.fail2ban_db_repo.get_history_page",
new=fake_get_history_page,
):
await history_sync._run_sync(fake_app)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
import re
import app
@@ -13,3 +14,15 @@ def test_app_version_matches_docker_version() -> None:
expected = version_file.read_text(encoding="utf-8").strip().lstrip("v")
assert app.__version__ == expected
def test_backend_pyproject_version_matches_docker_version() -> None:
repo_root = Path(__file__).resolve().parents[2]
version_file = repo_root / "Docker" / "VERSION"
expected = version_file.read_text(encoding="utf-8").strip().lstrip("v")
pyproject_file = repo_root / "backend" / "pyproject.toml"
text = pyproject_file.read_text(encoding="utf-8")
match = re.search(r"^version\s*=\s*\"([^\"]+)\"", text, re.MULTILINE)
assert match is not None, "backend/pyproject.toml must contain a version entry"
assert match.group(1) == expected

View File

@@ -72,7 +72,7 @@ dbfile = /var/lib/fail2ban/fail2ban.sqlite3
# Options: dbpurgeage
# Notes.: Sets age at which bans should be purged from the database
# Values: [ SECONDS ] Default: 86400 (24hours)
dbpurgeage = 1d
dbpurgeage = 648000
# Options: dbmaxmatches
# Notes.: Number of matches stored in database per ticket (resolvable via

View File

@@ -1,30 +1,33 @@
{
"name": "bangui-frontend",
"version": "0.9.10",
"version": "0.9.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bangui-frontend",
"version": "0.9.10",
"version": "0.9.17",
"dependencies": {
"@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257",
"@types/react-simple-maps": "^3.0.6",
"d3-geo": "^3.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"react-simple-maps": "^3.0.0",
"recharts": "^3.8.0"
"recharts": "^3.8.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/d3-geo": "^3.1.0",
"@types/node": "^25.3.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/topojson-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.13.0",
"@vitejs/plugin-react": "^4.3.3",
@@ -3565,23 +3568,15 @@
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
"integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "^2"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
@@ -3597,12 +3592,6 @@
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz",
"integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -3624,16 +3613,6 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-zoom": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz",
"integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "^2",
"@types/d3-selection": "^2"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3652,6 +3631,7 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -3696,16 +3676,25 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-simple-maps": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
"node_modules/@types/topojson-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-geo": "^2",
"@types/d3-zoom": "^2",
"@types/geojson": "*",
"@types/react": "*"
"@types/topojson-specification": "*"
}
},
"node_modules/@types/topojson-specification": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/use-sync-external-store": {
@@ -4476,28 +4465,6 @@
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
"license": "BSD-3-Clause"
},
"node_modules/d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-drag": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-selection": "2"
}
},
"node_modules/d3-ease": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
"license": "BSD-3-Clause"
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -4508,12 +4475,15 @@
}
},
"node_modules/d3-geo": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
"license": "BSD-3-Clause",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "^2.5.0"
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
@@ -4550,12 +4520,6 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -4592,41 +4556,6 @@
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-transition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-color": "1 - 2",
"d3-dispatch": "1 - 2",
"d3-ease": "1 - 2",
"d3-interpolate": "1 - 2",
"d3-timer": "1 - 2"
},
"peerDependencies": {
"d3-selection": "2"
}
},
"node_modules/d3-zoom": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-drag": "2",
"d3-interpolate": "1 - 2",
"d3-selection": "2",
"d3-transition": "2"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5745,16 +5674,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -5982,18 +5901,6 @@
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6110,23 +6017,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-simple-maps": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
"license": "MIT",
"dependencies": {
"d3-geo": "^2.0.2",
"d3-selection": "^2.0.0",
"d3-zoom": "^2.0.0",
"topojson-client": "^3.1.0"
},
"peerDependencies": {
"prop-types": "^15.7.2",
"react": "^16.8.0 || 17.x || 18.x",
"react-dom": "^16.8.0 || 17.x || 18.x"
}
},
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
@@ -7516,6 +7406,12 @@
"node": ">=0.10.0"
}
},
"node_modules/world-atlas": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
"license": "ISC"
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "bangui-frontend",
"private": true,
"version": "0.9.14",
"version": "0.9.18",
"description": "BanGUI frontend — fail2ban web management interface",
"type": "module",
"scripts": {
@@ -17,14 +17,17 @@
"dependencies": {
"@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257",
"@types/react-simple-maps": "^3.0.6",
"d3-geo": "^3.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"react-simple-maps": "^3.0.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"recharts": "^3.8.0"
},
"devDependencies": {
"@types/d3-geo": "^3.1.0",
"@types/topojson-client": "^3.0.0",
"@eslint/js": "^9.13.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",

View File

@@ -42,6 +42,7 @@ export async function fetchBans(
page = 1,
pageSize = 100,
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<DashboardBanListResponse> {
const params = new URLSearchParams({
range,
@@ -51,6 +52,9 @@ export async function fetchBans(
if (origin !== "all") {
params.set("origin", origin);
}
if (source !== "fail2ban") {
params.set("source", source);
}
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
}
@@ -66,11 +70,15 @@ export async function fetchBans(
export async function fetchBanTrend(
range: TimeRange,
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<BanTrendResponse> {
const params = new URLSearchParams({ range });
if (origin !== "all") {
params.set("origin", origin);
}
if (source !== "fail2ban") {
params.set("source", source);
}
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
}
@@ -86,10 +94,14 @@ export async function fetchBanTrend(
export async function fetchBansByJail(
range: TimeRange,
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<BansByJailResponse> {
const params = new URLSearchParams({ range });
if (origin !== "all") {
params.set("origin", origin);
}
if (source !== "fail2ban") {
params.set("source", source);
}
return get<BansByJailResponse>(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`);
}

View File

@@ -21,6 +21,7 @@ export async function fetchHistory(
if (query.origin) params.set("origin", query.origin);
if (query.jail) params.set("jail", query.jail);
if (query.ip) params.set("ip", query.ip);
if (query.source) params.set("source", query.source);
if (query.page !== undefined) params.set("page", String(query.page));
if (query.page_size !== undefined)
params.set("page_size", String(query.page_size));

View File

@@ -17,10 +17,14 @@ import type { BanOriginFilter } from "../types/ban";
export async function fetchBansByCountry(
range: TimeRange = "24h",
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<BansByCountryResponse> {
const params = new URLSearchParams({ range });
if (origin !== "all") {
params.set("origin", origin);
}
if (source !== "fail2ban") {
params.set("source", source);
}
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`);
}

View File

@@ -46,6 +46,10 @@ interface BanTableProps {
* Changing this value triggers a re-fetch and resets to page 1.
*/
origin?: BanOriginFilter;
/**
* Data source used for the table query.
*/
source?: "fail2ban" | "archive";
}
// ---------------------------------------------------------------------------
@@ -186,9 +190,9 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
* @param props.timeRange - Active time-range preset from the parent page.
* @param props.origin - Active origin filter from the parent page.
*/
export function BanTable({ timeRange, origin = "all" }: BanTableProps): React.JSX.Element {
export function BanTable({ timeRange, origin = "all", source = "fail2ban" }: BanTableProps): React.JSX.Element {
const styles = useStyles();
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin);
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin, source);
const banColumns = buildBanColumns(styles);

View File

@@ -53,6 +53,8 @@ interface BanTrendChartProps {
timeRange: TimeRange;
/** Origin filter controlling which bans are included. */
origin: BanOriginFilter;
/** Data source used for the chart. */
source?: "fail2ban" | "archive";
}
/** Internal chart data point shape. */
@@ -188,9 +190,10 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
export function BanTrendChart({
timeRange,
origin,
source = "fail2ban",
}: BanTrendChartProps): React.JSX.Element {
const styles = useStyles();
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin);
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin, source);
const isEmpty = buckets.every((b) => b.count === 0);
const entries = buildEntries(buckets, timeRange);

View File

@@ -8,6 +8,7 @@
import {
Divider,
Input,
Text,
ToggleButton,
Toolbar,
@@ -35,6 +36,14 @@ export interface DashboardFilterBarProps {
originFilter: BanOriginFilter;
/** Called when the user selects a different origin filter. */
onOriginFilterChange: (value: BanOriginFilter) => void;
/** Jail filter value (optional). */
jail?: string;
/** Called when the jail filter text changes (optional). */
onJailChange?: (value: string) => void;
/** IP address filter value (optional). */
ip?: string;
/** Called when the IP address filter text changes (optional). */
onIpChange?: (value: string) => void;
}
// ---------------------------------------------------------------------------
@@ -92,6 +101,10 @@ export function DashboardFilterBar({
onTimeRangeChange,
originFilter,
onOriginFilterChange,
jail,
onJailChange,
ip,
onIpChange,
}: DashboardFilterBarProps): React.JSX.Element {
const styles = useStyles();
const cardStyles = useCardStyles();
@@ -146,6 +159,48 @@ export function DashboardFilterBar({
))}
</Toolbar>
</div>
{onJailChange && (
<>
<div className={styles.divider}>
<Divider vertical />
</div>
<div className={styles.group}>
<Text weight="semibold" size={300}>
Jail
</Text>
<Input
placeholder="e.g. sshd"
size="small"
value={jail ?? ""}
onChange={(_ev, data): void => {
onJailChange(data.value);
}}
/>
</div>
</>
)}
{onIpChange && (
<>
<div className={styles.divider}>
<Divider vertical />
</div>
<div className={styles.group}>
<Text weight="semibold" size={300}>
IP Address
</Text>
<Input
placeholder="e.g. 192.168"
size="small"
value={ip ?? ""}
onChange={(_ev, data): void => {
onIpChange(data.value);
}}
/>
</div>
</>
)}
</div>
);
}

View File

@@ -1,31 +1,42 @@
/**
* WorldMap — SVG world map showing per-country ban counts.
*
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from
* jsDelivr CDN. For each country that has bans in the selected time window,
* the total count is displayed inside the country's borders. Clicking a
* country filters the companion table.
* Uses a local TopoJSON bundle and d3-geo for projection, path generation,
* and native SVG pan/zoom behaviour.
*/
import { createPortal } from "react-dom";
import { useCallback, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button, makeStyles, tokens } from "@fluentui/react-components";
import { geoMercator, geoPath, type GeoPath } from "d3-geo";
import { feature } from "topojson-client";
import type {
Feature,
FeatureCollection,
GeoJsonProperties,
Geometry,
} from "geojson";
import type {
GeometryCollection as TopoGeometryCollection,
Topology,
} from "topojson-specification";
import worldData from "world-atlas/countries-110m.json";
import { useCardStyles } from "../theme/commonStyles";
import type { GeoPermissibleObjects } from "d3-geo";
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
import { getBanCountColor } from "../utils/mapColors";
// ---------------------------------------------------------------------------
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
// ---------------------------------------------------------------------------
const GEO_URL =
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const MAP_WIDTH = 800;
const MAP_HEIGHT = 400;
const MIN_ZOOM = 1;
const MAX_ZOOM = 8;
const ZOOM_STEP = 0.5;
const PAN_THRESHOLD = 3;
const useStyles = makeStyles({
mapWrapper: {
@@ -33,6 +44,25 @@ const useStyles = makeStyles({
position: "relative",
overflow: "hidden",
},
svg: {
width: "100%",
height: "auto",
touchAction: "none",
},
country: {
transition: "fill 150ms ease, stroke 150ms ease",
stroke: tokens.colorNeutralStroke2,
strokeWidth: 0.75,
fill: "var(--country-fill)",
outline: "none",
cursor: "pointer",
},
countryHovered: {
fill: "var(--country-hover-fill)",
},
countrySelected: {
fill: "var(--country-selected-fill)",
},
countLabel: {
fontSize: "9px",
fontWeight: "600",
@@ -73,195 +103,21 @@ const useStyles = makeStyles({
},
});
// ---------------------------------------------------------------------------
// GeoLayer — must be rendered inside ComposableMap to access map context
// ---------------------------------------------------------------------------
type TopoJsonTopology = Topology & {
objects: {
countries: TopoGeometryCollection;
};
};
interface GeoLayerProps {
countries: Record<string, number>;
countryNames?: Record<string, string>;
selectedCountry: string | null;
onSelectCountry: (cc: string | null) => void;
thresholdLow: number;
thresholdMedium: number;
thresholdHigh: number;
}
function GeoLayer({
countries,
countryNames,
selectedCountry,
onSelectCountry,
thresholdLow,
thresholdMedium,
thresholdHigh,
}: GeoLayerProps): React.JSX.Element {
const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL });
const [tooltip, setTooltip] = useState<
| {
type TooltipState = {
cc: string;
count: number;
name: string;
x: number;
y: number;
}
| null
>(null);
} | null;
const handleClick = useCallback(
(cc: string | null): void => {
onSelectCountry(selectedCountry === cc ? null : cc);
},
[selectedCountry, onSelectCountry],
);
if (geographies.length === 0) return <></>;
// react-simple-maps types declare path as always defined, but it can be null
// during initial render before MapProvider context initializes. Cast to reflect
// the true runtime type and allow safe null checking.
const safePath = path as unknown as typeof path | null;
return (
<>
{(geographies as { rsmKey: string; id: string | number }[]).map(
(geo) => {
const numericId = String(geo.id);
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
const isSelected = cc !== null && selectedCountry === cc;
// Compute the fill color based on ban count
const fillColor = getBanCountColor(
count,
thresholdLow,
thresholdMedium,
thresholdHigh,
);
// Only calculate centroid if path is available
let cx: number | undefined;
let cy: number | undefined;
if (safePath != null) {
const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects);
[cx, cy] = centroid;
}
return (
<g
key={geo.rsmKey}
style={{ cursor: cc ? "pointer" : "default" }}
role={cc ? "button" : undefined}
tabIndex={cc ? 0 : undefined}
aria-label={cc
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
isSelected ? " (selected)" : ""
}`
: undefined}
aria-pressed={isSelected || undefined}
onClick={(): void => {
if (cc) handleClick(cc);
}}
onKeyDown={(e): void => {
if (cc && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleClick(cc);
}
}}
onMouseEnter={(e): void => {
if (!cc) return;
setTooltip({
cc,
count,
name: countryNames?.[cc] ?? cc,
x: e.clientX,
y: e.clientY,
});
}}
onMouseMove={(e): void => {
setTooltip((current) =>
current
? {
...current,
x: e.clientX,
y: e.clientY,
}
: current,
);
}}
onMouseLeave={(): void => {
setTooltip(null);
}}
>
<Geography
geography={geo}
style={{
default: {
fill: isSelected ? tokens.colorBrandBackground : fillColor,
stroke: tokens.colorNeutralStroke2,
strokeWidth: 0.75,
outline: "none",
},
hover: {
fill: isSelected
? tokens.colorBrandBackgroundHover
: cc && count > 0
? tokens.colorNeutralBackground3
: fillColor,
stroke: tokens.colorNeutralStroke1,
strokeWidth: 1,
outline: "none",
},
pressed: {
fill: cc ? tokens.colorBrandBackgroundPressed : fillColor,
stroke: tokens.colorBrandStroke1,
strokeWidth: 1,
outline: "none",
},
}}
/>
{count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
className={styles.countLabel}
>
{count}
</text>
)}
</g>
);
},
)}
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
role="tooltip"
aria-live="polite"
>
<span className={styles.tooltipCountry}>{tooltip.name}</span>
<span className={styles.tooltipCount}>
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
</span>
</div>,
document.body,
)}
</>
);
}
// ---------------------------------------------------------------------------
// WorldMap — public component
// ---------------------------------------------------------------------------
export interface WorldMapProps {
interface WorldMapProps {
/** ISO alpha-2 country code → ban count. */
countries: Record<string, number>;
/** Optional mapping from country code to display name. */
@@ -289,21 +145,143 @@ export function WorldMap({
}: WorldMapProps): React.JSX.Element {
const styles = useStyles();
const cardStyles = useCardStyles();
const [zoom, setZoom] = useState<number>(1);
const [zoom, setZoom] = useState<number>(MIN_ZOOM);
const [center, setCenter] = useState<[number, number]>([0, 0]);
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
const [tooltip, setTooltip] = useState<TooltipState>(null);
const handleZoomIn = (): void => {
setZoom((z) => Math.min(z + 0.5, 8));
const zoomRef = useRef<number>(zoom);
const centerRef = useRef<[number, number]>(center);
const dragStateRef = useRef<{
active: boolean;
startX: number;
startY: number;
startCenter: [number, number];
moved: boolean;
} | null>(null);
const clickSuppressedRef = useRef<boolean>(false);
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
useEffect(() => {
centerRef.current = center;
}, [center]);
const topology = useMemo(() => worldData as unknown as TopoJsonTopology, []);
const geoJson = useMemo(
() =>
feature(topology, topology.objects.countries) as FeatureCollection<
Geometry,
GeoJsonProperties
>,
[topology],
);
const projection = useMemo(
() => geoMercator().fitSize([MAP_WIDTH, MAP_HEIGHT], geoJson),
[geoJson],
);
const pathGenerator = useMemo<GeoPath<unknown, Feature<Geometry, GeoJsonProperties>>>(
() => geoPath().projection(projection),
[projection],
);
const countryFeatures = useMemo(
() => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null),
[geoJson.features],
);
const clampZoom = useCallback((value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM), []);
const handleCountrySelect = useCallback(
(cc: string | null): void => {
if (clickSuppressedRef.current) {
return;
}
onSelectCountry(selectedCountry === cc ? null : cc);
},
[onSelectCountry, selectedCountry],
);
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragStateRef.current = {
active: true,
startX: event.clientX,
startY: event.clientY,
startCenter: centerRef.current,
moved: false,
};
clickSuppressedRef.current = false;
}, []);
const handleZoomOut = (): void => {
setZoom((z) => Math.max(z - 0.5, 1));
};
const handlePointerMove = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
const drag = dragStateRef.current;
if (!drag?.active) return;
const handleResetView = (): void => {
setZoom(1);
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
drag.moved = true;
clickSuppressedRef.current = true;
}
setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]);
}, []);
const handlePointerUp = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
const drag = dragStateRef.current;
if (!drag) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
window.setTimeout(() => {
clickSuppressedRef.current = false;
}, 0);
}, []);
const handleWheel = useCallback((event: React.WheelEvent<SVGSVGElement>) => {
event.preventDefault();
const currentZoom = zoomRef.current;
const desiredZoom = clampZoom(currentZoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));
if (desiredZoom === currentZoom) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const svgX = (event.clientX - rect.left - centerRef.current[0]) / currentZoom;
const svgY = (event.clientY - rect.top - centerRef.current[1]) / currentZoom;
setZoom(desiredZoom);
setCenter([
centerRef.current[0] - svgX * (desiredZoom - currentZoom),
centerRef.current[1] - svgY * (desiredZoom - currentZoom),
]);
}, [clampZoom]);
const handleZoomIn = useCallback(() => {
setZoom((value) => clampZoom(value + ZOOM_STEP));
}, [clampZoom]);
const handleZoomOut = useCallback(() => {
setZoom((value) => clampZoom(value - ZOOM_STEP));
}, [clampZoom]);
const handleResetView = useCallback(() => {
setZoom(MIN_ZOOM);
setCenter([0, 0]);
};
}, []);
return (
<div
@@ -311,13 +289,12 @@ export function WorldMap({
role="img"
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
>
{/* Zoom controls */}
<div className={styles.zoomControls}>
<Button
appearance="secondary"
size="small"
onClick={handleZoomIn}
disabled={zoom >= 8}
disabled={zoom >= MAX_ZOOM}
title="Zoom in"
aria-label="Zoom in"
>
@@ -327,7 +304,7 @@ export function WorldMap({
appearance="secondary"
size="small"
onClick={handleZoomOut}
disabled={zoom <= 1}
disabled={zoom <= MIN_ZOOM}
title="Zoom out"
aria-label="Zoom out"
>
@@ -337,7 +314,7 @@ export function WorldMap({
appearance="secondary"
size="small"
onClick={handleResetView}
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
title="Reset view"
aria-label="Reset view"
>
@@ -345,34 +322,111 @@ export function WorldMap({
</Button>
</div>
<ComposableMap
projection="geoMercator"
projectionConfig={{ scale: 130, center: [10, 20] }}
width={800}
height={400}
style={{ width: "100%", height: "auto" }}
<svg
className={styles.svg}
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
role="img"
aria-label="World map showing banned IP counts by country."
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
>
<ZoomableGroup
zoom={zoom}
center={center}
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
setZoom(newZoom);
setCenter(coordinates);
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
{countryFeatures.map((featureItem) => {
const rawId = featureItem.id;
const numericId = String(Number(rawId));
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
const count = cc !== null ? countries[cc] ?? 0 : 0;
const isSelected = cc !== null && selectedCountry === cc;
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
const pathString = pathGenerator(featureItem) ?? "";
if (!pathString) {
return null;
}
return (
<g key={String(rawId)}>
<path
d={pathString}
role={cc ? "button" : undefined}
tabIndex={cc ? 0 : undefined}
aria-label={
cc
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
isSelected ? " (selected)" : ""
}`
: undefined
}
aria-pressed={isSelected || undefined}
className={`${styles.country} ${
isSelected ? styles.countrySelected : ""
} ${hoveredCountry === cc ? styles.countryHovered : ""}`}
style={
{
["--country-fill" as string]: fillColor,
["--country-hover-fill" as string]: isSelected
? tokens.colorBrandBackgroundHover
: tokens.colorBrandBackground2,
["--country-selected-fill" as string]: tokens.colorBrandBackground,
} as React.CSSProperties
}
onClick={(): void => {
if (cc) {
handleCountrySelect(cc);
}
}}
onKeyDown={(event): void => {
if (cc && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
handleCountrySelect(cc);
}
}}
onMouseEnter={(event): void => {
if (!cc) return;
setHoveredCountry(cc);
setTooltip({
cc,
count,
name: countryNames?.[cc] ?? cc,
x: event.clientX,
y: event.clientY,
});
}}
onMouseMove={(event): void => {
setTooltip((current) =>
current
? { ...current, x: event.clientX, y: event.clientY }
: current,
);
}}
onMouseLeave={(): void => {
setHoveredCountry(null);
setTooltip(null);
}}
minZoom={1}
maxZoom={8}
>
<GeoLayer
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry}
thresholdLow={thresholdLow}
thresholdMedium={thresholdMedium}
thresholdHigh={thresholdHigh}
/>
</ZoomableGroup>
</ComposableMap>
</g>
);
})}
</g>
</svg>
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
role="tooltip"
aria-live="polite"
>
<span className={styles.tooltipCountry}>{tooltip.name}</span>
<span className={styles.tooltipCount}>
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
</span>
</div>,
document.body,
)}
</div>
);
}

View File

@@ -125,4 +125,47 @@ describe("DashboardFilterBar", () => {
expect(onTimeRangeChange).toHaveBeenCalledOnce();
expect(onTimeRangeChange).toHaveBeenCalledWith("24h");
});
it("renders jail and ip input controls when provided", async () => {
const onJailChange = vi.fn();
const onIpChange = vi.fn();
render(
<FluentProvider theme={webLightTheme}>
<DashboardFilterBar
timeRange="24h"
onTimeRangeChange={vi.fn()}
originFilter="all"
onOriginFilterChange={vi.fn()}
jail=""
onJailChange={onJailChange}
ip=""
onIpChange={onIpChange}
/>
</FluentProvider>,
);
expect(screen.getByText(/Jail/i)).toBeInTheDocument();
expect(screen.getByText(/IP Address/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/e.g. sshd/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/e.g. 192.168/i)).toBeInTheDocument();
const jailInput = screen.getByPlaceholderText(/e.g. sshd/i);
const ipInput = screen.getByPlaceholderText(/e.g. 192.168/i);
const user = userEvent.setup();
await user.clear(jailInput);
await user.type(jailInput, "x");
expect(onJailChange).toHaveBeenLastCalledWith("x");
await user.clear(ipInput);
await user.type(ipInput, "1");
expect(onIpChange).toHaveBeenLastCalledWith("1");
});
it("does not render jail or ip inputs when handlers are missing", () => {
renderBar();
expect(screen.queryByText(/Jail/i)).toBeNull();
expect(screen.queryByText(/IP Address/i)).toBeNull();
});
});

View File

@@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
vi.mock("react-simple-maps", () => ({
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Geography: ({ children }: { children?: React.ReactNode }) => <g>{children}</g>,
useGeographies: () => ({
geographies: [{ rsmKey: "geo-1", id: 840 }],
path: { centroid: () => [10, 10] },
vi.mock(
"world-atlas/countries-110m.json",
() => ({
default: {
type: "Topology",
objects: {
countries: {
type: "GeometryCollection",
geometries: [
{
type: "Polygon",
arcs: [[0]],
id: "840",
},
],
},
},
arcs: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
transform: {
scale: [1, 1],
translate: [0, 0],
},
},
}),
}));
);
import { WorldMap } from "../WorldMap";
@@ -34,16 +49,20 @@ describe("WorldMap", () => {
</FluentProvider>,
);
// Tooltip should not be present initially
expect(screen.queryByRole("tooltip")).toBeNull();
const countryButton = screen.getByRole("button", { name: /US: 42 bans/i });
const countryButton = screen.getByRole("button", { name: "US: 42 bans" });
expect(countryButton).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Zoom in/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Zoom out/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Reset view/i })).toBeInTheDocument();
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("United States");
expect(tooltip).toHaveTextContent("42 bans");
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
fireEvent.mouseLeave(countryButton);
expect(screen.queryByRole("tooltip")).toBeNull();

View File

@@ -8,6 +8,7 @@
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
"4": "AF",
"8": "AL",
"10": "AQ",
"12": "DZ",
"16": "AS",
"20": "AD",
@@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
"148": "TD",
"152": "CL",
"156": "CN",
"158": "TW",
"162": "CX",
"166": "CC",
"170": "CO",
@@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
"250": "FR",
"254": "GF",
"258": "PF",
"260": "TF",
"262": "DJ",
"266": "GA",
"268": "GE",
@@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
"372": "IE",
"376": "IL",
"380": "IT",
"384": "CI",
"388": "JM",
"392": "JP",
"398": "KZ",

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import * as configApi from "../../api/config";
import { useActionConfig } from "../useActionConfig";
vi.mock("../../api/config");
describe("useActionConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(configApi.fetchAction).mockResolvedValue({
name: "iptables",
filename: "iptables.conf",
source_file: "/etc/fail2ban/action.d/iptables.conf",
active: false,
used_by_jails: [],
before: null,
after: null,
actionstart: "",
actionstop: "",
actioncheck: "",
actionban: "",
actionunban: "",
actionflush: "",
definition_vars: {},
init_vars: {},
has_local_override: false,
});
vi.mocked(configApi.updateAction).mockResolvedValue(undefined);
});
it("calls fetchAction exactly once for stable name and rerenders", async () => {
const { rerender } = renderHook(
({ name }) => useActionConfig(name),
{ initialProps: { name: "iptables" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
// Rerender with the same action name; fetch should not be called again.
rerender({ name: "iptables" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
});
it("calls fetchAction again when name changes", async () => {
const { rerender } = renderHook(
({ name }) => useActionConfig(name),
{ initialProps: { name: "iptables" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
rerender({ name: "ssh" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import * as configApi from "../../api/config";
import { useFilterConfig } from "../useFilterConfig";
vi.mock("../../api/config");
describe("useFilterConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(configApi.fetchParsedFilter).mockResolvedValue({
name: "sshd",
filename: "sshd.conf",
source_file: "/etc/fail2ban/filter.d/sshd.conf",
active: false,
used_by_jails: [],
before: null,
after: null,
variables: {},
prefregex: null,
failregex: [],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
has_local_override: false,
});
vi.mocked(configApi.updateParsedFilter).mockResolvedValue(undefined);
});
it("calls fetchParsedFilter only once for stable name", async () => {
const { rerender } = renderHook(
({ name }) => useFilterConfig(name),
{ initialProps: { name: "sshd" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
rerender({ name: "sshd" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
});
it("calls fetchParsedFilter again when name changes", async () => {
const { rerender } = renderHook(
({ name }) => useFilterConfig(name),
{ initialProps: { name: "sshd" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
rerender({ name: "apache-auth" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(2);
});
});

View File

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed action config.
*/
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem";
import { fetchAction, updateAction } from "../api/config";
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
@@ -23,12 +24,18 @@ export interface UseActionConfigResult {
* @param name - Action base name (e.g. ``"iptables"``).
*/
export function useActionConfig(name: string): UseActionConfigResult {
const fetchFn = useCallback(() => fetchAction(name), [name]);
const saveFn = useCallback(
(update: ActionConfigUpdate) => updateAction(name, update),
[name],
);
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
ActionConfig,
ActionConfigUpdate
>({
fetchFn: () => fetchAction(name),
saveFn: (update) => updateAction(name, update),
fetchFn,
saveFn,
mergeOnSave: (prev, update) =>
prev
? {

View File

@@ -42,6 +42,7 @@ export interface UseBanTrendResult {
export function useBanTrend(
timeRange: TimeRange,
origin: BanOriginFilter,
source: "fail2ban" | "archive" = "fail2ban",
): UseBanTrendResult {
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
const [bucketSize, setBucketSize] = useState<string>("1h");
@@ -58,7 +59,7 @@ export function useBanTrend(
setIsLoading(true);
setError(null);
fetchBanTrend(timeRange, origin)
fetchBanTrend(timeRange, origin, source)
.then((data) => {
if (controller.signal.aborted) return;
setBuckets(data.buckets);
@@ -73,7 +74,7 @@ export function useBanTrend(
setIsLoading(false);
}
});
}, [timeRange, origin]);
}, [timeRange, origin, source]);
useEffect(() => {
load();

View File

@@ -44,6 +44,7 @@ export interface UseBansResult {
export function useBans(
timeRange: TimeRange,
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): UseBansResult {
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
const [total, setTotal] = useState<number>(0);
@@ -51,16 +52,16 @@ export function useBans(
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Reset page when time range or origin filter changes.
// Reset page when time range, origin filter, or source changes.
useEffect(() => {
setPage(1);
}, [timeRange, origin]);
}, [timeRange, origin, source]);
const doFetch = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin);
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin, source);
setBanItems(data.items);
setTotal(data.total);
} catch (err: unknown) {
@@ -68,7 +69,7 @@ export function useBans(
} finally {
setLoading(false);
}
}, [timeRange, page, origin]);
}, [timeRange, page, origin, source]);
// Stable ref to the latest doFetch so the refresh callback is always current.
const doFetchRef = useRef(doFetch);

View File

@@ -48,6 +48,7 @@ export interface UseDashboardCountryDataResult {
export function useDashboardCountryData(
timeRange: TimeRange,
origin: BanOriginFilter,
source: "fail2ban" | "archive" = "fail2ban",
): UseDashboardCountryDataResult {
const [countries, setCountries] = useState<Record<string, number>>({});
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
@@ -67,7 +68,7 @@ export function useDashboardCountryData(
setIsLoading(true);
setError(null);
fetchBansByCountry(timeRange, origin)
fetchBansByCountry(timeRange, origin, source)
.then((data) => {
if (controller.signal.aborted) return;
setCountries(data.countries);
@@ -85,7 +86,7 @@ export function useDashboardCountryData(
setIsLoading(false);
}
});
}, [timeRange, origin]);
}, [timeRange, origin, source]);
useEffect(() => {
load();

View File

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed filter config.
*/
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem";
import { fetchParsedFilter, updateParsedFilter } from "../api/config";
import type { FilterConfig, FilterConfigUpdate } from "../types/config";
@@ -23,12 +24,18 @@ export interface UseFilterConfigResult {
* @param name - Filter base name (e.g. ``"sshd"``).
*/
export function useFilterConfig(name: string): UseFilterConfigResult {
const fetchFn = useCallback(() => fetchParsedFilter(name), [name]);
const saveFn = useCallback(
(update: FilterConfigUpdate) => updateParsedFilter(name, update),
[name],
);
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
FilterConfig,
FilterConfigUpdate
>({
fetchFn: () => fetchParsedFilter(name),
saveFn: (update) => updateParsedFilter(name, update),
fetchFn,
saveFn,
mergeOnSave: (prev, update) =>
prev
? {

View File

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed jail.d config file.
*/
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem";
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
@@ -21,12 +22,18 @@ export interface UseJailFileConfigResult {
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
*/
export function useJailFileConfig(filename: string): UseJailFileConfigResult {
const fetchFn = useCallback(() => fetchParsedJailFile(filename), [filename]);
const saveFn = useCallback(
(update: JailFileConfigUpdate) => updateParsedJailFile(filename, update),
[filename],
);
const { data, loading, error, refresh, save } = useConfigItem<
JailFileConfig,
JailFileConfigUpdate
>({
fetchFn: () => fetchParsedJailFile(filename),
saveFn: (update) => updateParsedJailFile(filename, update),
fetchFn,
saveFn,
mergeOnSave: (prev, update) =>
update.jails != null && prev
? { ...prev, jails: { ...prev.jails, ...update.jails } }

View File

@@ -43,6 +43,7 @@ export interface UseMapDataResult {
export function useMapData(
range: TimeRange = "24h",
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): UseMapDataResult {
const [data, setData] = useState<BansByCountryResponse | null>(null);
const [loading, setLoading] = useState(true);
@@ -64,7 +65,7 @@ export function useMapData(
abortRef.current?.abort();
abortRef.current = new AbortController();
fetchBansByCountry(range, origin)
fetchBansByCountry(range, origin, source)
.then((resp) => {
setData(resp);
})
@@ -75,7 +76,7 @@ export function useMapData(
setLoading(false);
});
}, DEBOUNCE_MS);
}, [range, origin]);
}, [range, origin, source]);
useEffect((): (() => void) => {
load();
@@ -97,3 +98,21 @@ export function useMapData(
refresh: load,
};
}
/**
* Test helper: returns arguments most recently used to call `useMapData`.
*
* This helper is only intended for test use with a mock implementation.
*/
export function getLastArgs(): { range: string; origin: string } {
throw new Error("getLastArgs is only available in tests with a mocked useMapData");
}
/**
* Test helper: mutates mocked map data state.
*
* This helper is only intended for test use with a mock implementation.
*/
export function setMapData(_: Partial<UseMapDataResult>): void {
throw new Error("setMapData is only available in tests with a mocked useMapData");
}

View File

@@ -71,8 +71,10 @@ export function DashboardPage(): React.JSX.Element {
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const source = timeRange === "24h" ? "fail2ban" : "archive";
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
useDashboardCountryData(timeRange, originFilter);
useDashboardCountryData(timeRange, originFilter, source);
const sectionStyles = useCommonSectionStyles();
@@ -86,12 +88,14 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */}
{/* Global filter bar */}
{/* ------------------------------------------------------------------ */}
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
<DashboardFilterBar
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
originFilter={originFilter}
onOriginFilterChange={setOriginFilter}
/>
</div>
{/* ------------------------------------------------------------------ */}
{/* Ban Trend section */}
@@ -103,7 +107,7 @@ export function DashboardPage(): React.JSX.Element {
</Text>
</div>
<div className={styles.tabContent}>
<BanTrendChart timeRange={timeRange} origin={originFilter} />
<BanTrendChart timeRange={timeRange} origin={originFilter} source={source} />
</div>
</div>
@@ -154,7 +158,7 @@ export function DashboardPage(): React.JSX.Element {
{/* Ban table */}
<div className={styles.tabContent}>
<BanTable timeRange={timeRange} origin={originFilter} />
<BanTable timeRange={timeRange} origin={originFilter} source={source} />
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
* Rows with repeatedly-banned IPs are highlighted in amber.
*/
import { useCallback, useState } from "react";
import { useEffect, useState } from "react";
import {
Badge,
Button,
@@ -16,7 +16,6 @@ import {
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Input,
MessageBar,
MessageBarBody,
Spinner,
@@ -82,11 +81,6 @@ const useStyles = makeStyles({
gap: tokens.spacingHorizontalM,
flexWrap: "wrap",
},
filterLabel: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
tableWrapper: {
overflow: "auto",
borderRadius: tokens.borderRadiusMedium,
@@ -136,6 +130,25 @@ const useStyles = makeStyles({
},
});
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function areHistoryQueriesEqual(
a: HistoryQuery,
b: HistoryQuery,
): boolean {
return (
a.range === b.range &&
a.origin === b.origin &&
a.jail === b.jail &&
a.ip === b.ip &&
a.source === b.source &&
a.page === b.page &&
a.page_size === b.page_size
);
}
// ---------------------------------------------------------------------------
// Column definitions for the main history table
// ---------------------------------------------------------------------------
@@ -374,11 +387,12 @@ export function HistoryPage(): React.JSX.Element {
const styles = useStyles();
// Filter state
const [range, setRange] = useState<TimeRange>("24h");
const [range, setRange] = useState<TimeRange>("7d");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState("");
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
source: "archive",
page_size: PAGE_SIZE,
});
@@ -388,15 +402,24 @@ export function HistoryPage(): React.JSX.Element {
const { items, total, page, loading, error, setPage, refresh } =
useHistory(appliedQuery);
const applyFilters = useCallback((): void => {
setAppliedQuery({
range: range,
useEffect((): void => {
const nextQuery: HistoryQuery = {
range,
origin: originFilter !== "all" ? originFilter : undefined,
jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined,
source: "archive",
page: 1,
page_size: PAGE_SIZE,
});
}, [range, originFilter, jailFilter, ipFilter]);
};
if (areHistoryQueriesEqual(nextQuery, appliedQuery)) {
return;
}
setPage(1);
setAppliedQuery(nextQuery);
}, [range, originFilter, jailFilter, ipFilter, setPage, appliedQuery]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
@@ -456,71 +479,17 @@ export function HistoryPage(): React.JSX.Element {
onOriginFilterChange={(value) => {
setOriginFilter(value);
}}
/>
<div className={styles.filterLabel}>
<Text size={200}>Jail</Text>
<Input
placeholder="e.g. sshd"
value={jailFilter}
onChange={(_ev, data): void => {
setJailFilter(data.value);
jail={jailFilter}
onJailChange={(value) => {
setJailFilter(value);
}}
size="small"
/>
</div>
<div className={styles.filterLabel}>
<Text size={200}>IP Address</Text>
<Input
placeholder="e.g. 192.168"
value={ipFilter}
onChange={(_ev, data): void => {
setIpFilter(data.value);
}}
size="small"
onKeyDown={(e): void => {
if (e.key === "Enter") applyFilters();
ip={ipFilter}
onIpChange={(value) => {
setIpFilter(value);
}}
/>
</div>
<Button appearance="primary" size="small" onClick={applyFilters}>
Apply
</Button>
<Button
appearance="subtle"
size="small"
onClick={(): void => {
setRange("24h");
setOriginFilter("all");
setJailFilter("");
setIpFilter("");
setAppliedQuery({ page_size: PAGE_SIZE });
}}
>
Clear
</Button>
</div>
{/* ---------------------------------------------------------------- */}
{/* Error / loading state */}
{/* ---------------------------------------------------------------- */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{loading && !error && (
<div
style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}
>
<Spinner label="Loading history…" />
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* Summary */}
{/* ---------------------------------------------------------------- */}

View File

@@ -25,7 +25,12 @@ import {
makeStyles,
tokens,
} from "@fluentui/react-components";
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
import {
ArrowCounterclockwiseRegular,
ChevronLeftRegular,
ChevronRightRegular,
DismissRegular,
} from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { WorldMap } from "../components/WorldMap";
import { useMapData } from "../hooks/useMapData";
@@ -68,6 +73,15 @@ const useStyles = makeStyles({
borderRadius: tokens.borderRadiusMedium,
backgroundColor: tokens.colorNeutralBackground2,
},
pagination: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
backgroundColor: tokens.colorNeutralBackground2,
},
});
// ---------------------------------------------------------------------------
@@ -79,9 +93,15 @@ export function MapPage(): React.JSX.Element {
const [range, setRange] = useState<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(100);
const PAGE_SIZE_OPTIONS = [25, 50, 100] as const;
const source = range === "24h" ? "fail2ban" : "archive";
const { countries, countryNames, bans, total, loading, error, refresh } =
useMapData(range, originFilter);
useMapData(range, originFilter, source);
const {
thresholds: mapThresholds,
@@ -99,6 +119,10 @@ export function MapPage(): React.JSX.Element {
}
}, [mapThresholdError]);
useEffect(() => {
setPage(1);
}, [range, originFilter, selectedCountry, bans, pageSize]);
/** Bans visible in the companion table (filtered by selected country). */
const visibleBans = useMemo(() => {
if (!selectedCountry) return bans;
@@ -109,6 +133,15 @@ export function MapPage(): React.JSX.Element {
? (countryNames[selectedCountry] ?? selectedCountry)
: null;
const totalPages = Math.max(1, Math.ceil(visibleBans.length / pageSize));
const hasPrev = page > 1;
const hasNext = page < totalPages;
const pageBans = useMemo(() => {
const start = (page - 1) * pageSize;
return visibleBans.slice(start, start + pageSize);
}, [visibleBans, page, pageSize]);
return (
<div className={styles.root}>
{/* ---------------------------------------------------------------- */}
@@ -235,7 +268,7 @@ export function MapPage(): React.JSX.Element {
</TableCell>
</TableRow>
) : (
visibleBans.map((ban) => (
pageBans.map((ban) => (
<TableRow key={`${ban.ip}-${ban.banned_at}`}>
<TableCell>
<TableCellLayout>{ban.ip}</TableCellLayout>
@@ -282,6 +315,53 @@ export function MapPage(): React.JSX.Element {
)}
</TableBody>
</Table>
<div className={styles.pagination}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Showing {pageBans.length} of {visibleBans.length} filtered ban{visibleBans.length !== 1 ? "s" : ""}
{" · "}Page {page} of {totalPages}
</Text>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Page size
</Text>
<select
aria-label="Page size"
value={pageSize}
onChange={(event): void => {
setPageSize(Number(event.target.value));
}}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
<div style={{ display: "flex", gap: tokens.spacingHorizontalXS }}>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
disabled={!hasPrev}
onClick={(): void => {
setPage(page - 1);
}}
aria-label="Previous page"
/>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
disabled={!hasNext}
onClick={(): void => {
setPage(page + 1);
}}
aria-label="Next page"
/>
</div>
</div>
</div>
)}
</div>

View File

@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { HistoryPage } from "../HistoryPage";
let lastQuery: Record<string, unknown> | null = null;
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
console.log("mockUseHistory called", query);
lastQuery = query;
return {
items: [],
@@ -18,16 +18,16 @@ const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
};
});
vi.mock("../hooks/useHistory", () => ({
vi.mock("../../hooks/useHistory", () => ({
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
}));
vi.mock("../components/WorldMap", () => ({
vi.mock("../../components/WorldMap", () => ({
WorldMap: () => <div data-testid="world-map" />,
}));
vi.mock("../api/config", () => ({
vi.mock("../../api/config", () => ({
fetchMapColorThresholds: async () => ({
threshold_low: 10,
threshold_medium: 50,
@@ -35,8 +35,10 @@ vi.mock("../api/config", () => ({
}),
}));
import { HistoryPage } from "../HistoryPage";
describe("HistoryPage", () => {
it("renders DashboardFilterBar and applies origin+range filters", async () => {
it("auto-applies filters on change and hides apply/clear actions", async () => {
const user = userEvent.setup();
render(
@@ -45,14 +47,31 @@ describe("HistoryPage", () => {
</FluentProvider>,
);
// Initial load should include the default query.
expect(lastQuery).toEqual({ page_size: 50 });
// Initial load should include the auto-applied default query.
await waitFor(() => {
expect(lastQuery).toEqual({
range: "7d",
source: "archive",
origin: undefined,
jail: undefined,
ip: undefined,
page: 1,
page_size: 50,
});
});
// Change the time-range and origin filter, then apply.
expect(screen.queryByRole("button", { name: /apply/i })).toBeNull();
expect(screen.queryByRole("button", { name: /clear/i })).toBeNull();
// Time-range and origin updates should be applied automatically.
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
await user.click(screen.getByRole("button", { name: /Apply/i }));
await waitFor(() => {
expect(lastQuery).toMatchObject({ range: "7d" });
});
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" });
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
await waitFor(() => {
expect(lastQuery).toMatchObject({ origin: "blocklist" });
});
});
});

View File

@@ -2,42 +2,43 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { getLastArgs, setMapData } from "../../hooks/useMapData";
import { MapPage } from "../MapPage";
const mockFetchMapColorThresholds = vi.fn(async () => ({
threshold_low: 10,
threshold_medium: 50,
threshold_high: 100,
}));
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
const mockUseMapData = vi.fn((range: string, origin: string) => {
lastArgs = { range, origin };
return {
vi.mock("../../hooks/useMapData", () => {
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
let dataState = {
countries: {},
countryNames: {},
bans: [],
total: 0,
loading: false,
error: null,
refresh: vi.fn(),
refresh: () => {},
};
return {
useMapData: (range: string, origin: string) => {
lastArgs = { range, origin };
return { ...dataState };
},
setMapData: (newState: Partial<typeof dataState>) => {
dataState = { ...dataState, ...newState };
},
getLastArgs: () => lastArgs,
};
});
vi.mock("../hooks/useMapData", () => ({
useMapData: (range: string, origin: string) => mockUseMapData(range, origin),
vi.mock("../../api/config", () => ({
fetchMapColorThresholds: vi.fn(async () => ({
threshold_low: 10,
threshold_medium: 50,
threshold_high: 100,
})),
}));
vi.mock("../api/config", async () => ({
fetchMapColorThresholds: mockFetchMapColorThresholds,
}));
const mockWorldMap = vi.fn((_props: unknown) => <div data-testid="world-map" />);
vi.mock("../components/WorldMap", () => ({
WorldMap: (props: unknown) => {
mockWorldMap(props);
return <div data-testid="world-map" />;
},
vi.mock("../../components/WorldMap", () => ({
WorldMap: () => <div data-testid="world-map" />,
}));
describe("MapPage", () => {
@@ -51,17 +52,63 @@ describe("MapPage", () => {
);
// Initial load should call useMapData with default filters.
expect(lastArgs).toEqual({ range: "24h", origin: "all" });
// Map should receive country names from the hook so tooltips can show human-readable labels.
expect(mockWorldMap).toHaveBeenCalled();
const firstCallArgs = mockWorldMap.mock.calls[0]?.[0];
expect(firstCallArgs).toMatchObject({ countryNames: {} });
expect(getLastArgs()).toEqual({ range: "24h", origin: "all" });
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
expect(lastArgs.range).toBe("7d");
expect(getLastArgs().range).toBe("7d");
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
expect(lastArgs.origin).toBe("blocklist");
expect(getLastArgs().origin).toBe("blocklist");
});
it("supports pagination with 100 items per page and reset on filter changes", async () => {
const user = userEvent.setup();
const bans: import("../../types/map").MapBanItem[] = Array.from({ length: 120 }, (_, index) => ({
ip: `192.0.2.${index}`,
jail: "ssh",
banned_at: new Date(Date.now() - index * 1000).toISOString(),
service: null,
country_code: "US",
country_name: "United States",
asn: null,
org: null,
ban_count: 1,
origin: "selfblock",
}));
setMapData({
countries: { US: 120 },
countryNames: { US: "United States" },
bans,
total: 120,
loading: false,
error: null,
});
render(
<FluentProvider theme={webLightTheme}>
<MapPage />
</FluentProvider>,
);
expect(await screen.findByText(/Page 1 of 2/i)).toBeInTheDocument();
expect(screen.getByText(/Showing 100 of 120 filtered bans/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Next page/i }));
expect(await screen.findByText(/Page 2 of 2/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Previous page/i }));
expect(await screen.findByText(/Page 1 of 2/i)).toBeInTheDocument();
// Page size selector should adjust pagination
await user.selectOptions(screen.getByRole("combobox", { name: /Page size/i }), "25");
expect(await screen.findByText(/Page 1 of 5/i)).toBeInTheDocument();
expect(screen.getByText(/Showing 25 of 120 filtered bans/i)).toBeInTheDocument();
// Changing filter keeps page reset to 1
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
expect(getLastArgs().origin).toBe("blocklist");
expect(await screen.findByText(/Page 1 of 5/i)).toBeInTheDocument();
});
});

View File

@@ -57,6 +57,7 @@ export interface HistoryQuery {
origin?: BanOriginFilter;
jail?: string;
ip?: string;
source?: "fail2ban" | "archive";
page?: number;
page_size?: number;
}

View File

@@ -8,6 +8,8 @@
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",