17 Commits

Author SHA1 Message Date
96f75db75f chore: release v0.9.19 2026-04-05 22:47:42 +02:00
554c75247f Update Docs/Tasks.md 2026-04-05 22:45:41 +02:00
6e2abe9d97 Fix world map country selection handling and preserve map during re-fetch 2026-04-05 22:44:50 +02:00
15d53a8e96 Merge branch 'fix/worldmap-hover-highlight' 2026-04-05 22:19:35 +02:00
acdb0e1f03 backup 2026-04-05 22:18:06 +02:00
f1e3d4c4c9 Make Map companion table header and pagination sticky 2026-04-05 22:17:24 +02:00
c51858ec71 Add country-specific companion table filtering for map page 2026-04-05 22:12:06 +02:00
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
40 changed files with 967 additions and 587 deletions

View File

@@ -1 +1 @@
v0.9.15 v0.9.19

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` Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
(see `fail2ban/paths-lsio.conf``remote_logs_path = /remotelogs`). (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`: To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
```ini ```ini

View File

@@ -52,6 +52,8 @@ The main landing page after login. Shows recent ban activity at a glance.
- Last 7 days (week) - Last 7 days (week)
- Last 30 days (month) - Last 30 days (month)
- Last 365 days (year) - 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,21 @@ A geographical overview of ban activity.
- Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend) - 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 - 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. - **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. - 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 number and no label — they remain blank and transparent. - 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. - Clicking a country filters the companion table below to show only bans from that country. When a country is selected the server returns the **complete** list of bans for that country in the chosen time window — the default 200-row companion cap is lifted for filtered queries. Clicking the same country again or using the "Clear filter" button reverts to the standard unfiltered view.
- Time-range selector with the same quick presets: - Time-range selector with the same quick presets:
- Last 24 hours - Last 24 hours
- Last 7 days - Last 7 days
- Last 30 days - Last 30 days
- Last 365 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)**.
### Companion Table
- The column header row is always visible at the top of the scrollable table area (sticky positioning) so column labels remain readable regardless of scroll position.
- The pagination / page-size bar is always visible at the bottom of the scrollable table area (sticky positioning) so the user can navigate pages without scrolling back down.
--- ---
@@ -245,13 +254,15 @@ A page to inspect and modify the fail2ban configuration without leaving the web
## 7. Ban History ## 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 ### History Table
- Browse all past bans across all jails, not just the currently active ones. - 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. - **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. - 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). - See at a glance which IPs are repeat offenders (high ban count).
### Per-IP History ### Per-IP History
@@ -265,7 +276,7 @@ A view for exploring historical ban data stored in the fail2ban database.
- 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. - 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. - 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. - 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 last N days (configurable, default 7 days) of existing fail2ban history into BanGUI to avoid dark gaps after restart. - 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. - 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. - 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. - On fail2ban connectivity failure, BanGUI continues serving historical data; next successful sync resumes ingestion without data loss.

View File

@@ -8,56 +8,66 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
## Open Issues ## Open Issues
### History page filters and behavior (frontend) ---
1. [x] Time range filter currently not working ### TASK-001 — WorldMap: filter companion table by selected country (server-side)
- Scope: frontend `history` page state/logic (likely in `frontend/src` route or component for history table)
- Expected behavior: selecting start/end timestamps (or presets) should filter history rows by event timestamp in the backend query or local data, then rerender filtered results immediately.
- Investigation:
- check component(s) handling time range input, change handlers, and query construction.
- confirm backend API supports `startTime`/`endTime` parameters for history endpoint (`/api/history` or similar).
- confirm values are passed as ISO strings/epoch and matched in backend.
- Fix should include:
- wiring the time range inputs to the filtering state.
- constructing API query with time range params.
- verifying with unit/integration tests plus manual through UI.
2. [x] Global filter (All | Blocklist | Selfblock) not working **Status:** Done
- Scope: frontend `history` page filter chips or select control. **Priority:** Medium
- Expected behavior: choosing `All`, `Blocklist`, `Selfblock` should apply corresponding filter in same history query (no results for unmatched types). **Domain:** Full-stack (backend + frontend)
- Tasks: **References:** `Docs/Features.md §4`, `Docs/Web-Development.md`
- locate filter control component and event handlers.
- validate value mapping semantics (`all`=>no filter, `blocklist`=>source=blocklist, `selfblock`=>source=selfblock or equivalent).
- ensure filter value is merged with time range + jail/ip filters in API call.
- add tests for each filter option to confirm behavior.
3. [x] Jail and IP address filters UX alignment with time range #### Background
- Scope: frontend `history` page layout/CSS component; likely `HistoryFilters` container.
- Expected behavior:
- Jail filter and IP address filter should be same look (input/select style and spacing) as time range widget.
- Place Jail/IP filters next to time range controls in same row.
- Should be responsive and consistent.
- Tasks:
- modify the `history` filter container markup so time range, jail, ip filter are co-located.
- apply matching CSS classes/styles to Jail/IP as existing time range control.
- verify cross-browser/responsive display with storybook/test if exists.
4. [x] Remove “Apply” and “Clear” buttons; auto-apply on field change The `GET /api/dashboard/bans/by-country` endpoint always returns the **200 most recent** ban rows in `bans` (constant `_MAX_COMPANION_BANS = 200` in `backend/app/services/ban_service.py`). `MapPage.tsx` stores a `selectedCountry` state and filters the returned rows client-side via `visibleBans`. This means the companion table can only show the fraction of a country's bans that fall within the global top-200 window. If the selected time range has, say, 1 500 bans and 300 are from China, but China's bans are not all in the top 200 overall, the table will silently display fewer than 300 rows.
- Scope: frontend `history` filter form behavior.
- Expected behavior:
- Any filter field change (time range, type, jail, IP) triggers immediate query update (debounced 150-300 ms if needed).
- Remove explicit “Apply” and “Clear” buttons from UI.
- Clear can be replaced by resetting fields automatically or via a small “reset” icon if needed.
- Implementation steps:
- remove button UI elements and event bindings from history page component.
- make each filter input onChange call the shared `applyFilters` logic with current state.
- add debounce to avoid 100% rapid API churn.
- for clear semantics, ensure default/empty state on filter input binds to default query (All).
- add tests to verify no apply/clear buttons present and updates via input change.
### Acceptance criteria When a country is selected the companion table **must** return the complete set of bans for that country so the user sees an accurate picture.
- On `history` page, time range selection + filter chips + jail/ip are functional and produce filtered results.
- Time range, jail/IP inputs are styled consistently and in same filter row.
- No apply/clear buttons are visible and filter updates occur on value change (with optional debounce).
- Relevant tests added/updated in frontend test suite.
#### Desired behaviour
- No country selected → companion table shows the 200 most recent bans across all countries (existing behaviour, no change).
- Country selected → the server returns **all** ban entries for that country in the selected time window; no client-side row-count cap applies.
- Deselecting a country (clicking the same country again, or the "Clear filter" button) reverts to the default 200-row unfiltered view.
- The existing `visibleBans` client-side filter in `MapPage.tsx` can remain as a defensive guard but must not be the only filter.
#### Implementation steps
1. **Backend — router** (`backend/app/routers/dashboard.py`)
- Add `country_code: str | None = Query(default=None, description="ISO alpha-2 country code to filter companion rows.")` to `get_bans_by_country`.
- Pass it to `ban_service.bans_by_country(..., country_code=country_code)`.
2. **Backend — service** (`backend/app/services/ban_service.py`)
- Add `country_code: str | None = None` keyword argument to `bans_by_country`.
- After `geo_map` is built (existing geo-resolution step), collect IPs whose resolved country matches `country_code`.
- For the **fail2ban source**: call `fail2ban_db_repo.get_currently_banned` with `ip_filter=matched_ips` and no `limit` (remove the `_MAX_COMPANION_BANS` cap for filtered queries).
- For the **archive source**: filter `all_rows` to those whose IP is in `matched_ips` and return all of them (skip the `page_size=_MAX_COMPANION_BANS` call).
- When `country_code` is `None`, behaviour is identical to today.
3. **Backend — repository** (`backend/app/repositories/fail2ban_db_repo.py`)
- Add `ip_filter: list[str] | None = None` to `get_currently_banned`.
- When provided and non-empty, append `AND ip IN ({placeholders})` to the SQL `WHERE` clause, parameterised safely (never interpolated as a string).
4. **Backend — repository (archive)** (`backend/app/repositories/history_archive_repo.py`)
- Similarly add optional `ip_filter` to the archive companion-rows query used from `bans_by_country`.
5. **Frontend — API client** (`frontend/src/api/map.ts`)
- Add optional `countryCode?: string` parameter to `fetchBansByCountry`.
- When set, append `country_code=<value>` to the query string.
6. **Frontend — hook** (`frontend/src/hooks/useMapData.ts`)
- Add `countryCode?: string` to the function signature.
- Include it in the `useCallback` dependency array and pass it to `fetchBansByCountry`.
7. **Frontend — page** (`frontend/src/pages/MapPage.tsx`)
- Pass `selectedCountry ?? undefined` as `countryCode` to `useMapData`.
- The hook's effect will re-fetch automatically when `selectedCountry` changes; the existing `useEffect` that resets `page` to 1 already covers this.
#### Testing guidance
- Select a country that has > 200 bans in the chosen time window; confirm the companion table shows more than the previous cap would allow.
- With no country selected, confirm only 200 rows are returned (no regression).
- Deselect the country; confirm the unfiltered 200-row view is restored.
- Test with the archive source as well as the fail2ban live source.
- Verify the `ip_filter` SQL clause is parameterised and cannot be injected.
---

View File

@@ -126,6 +126,7 @@ async def get_currently_banned(
since: int, since: int,
origin: BanOrigin | None = None, origin: BanOrigin | None = None,
*, *,
ip_filter: list[str] | None = None,
limit: int | None = None, limit: int | None = None,
offset: int | None = None, offset: int | None = None,
) -> tuple[list[BanRecord], int]: ) -> tuple[list[BanRecord], int]:
@@ -135,6 +136,7 @@ async def get_currently_banned(
db_path: File path to the fail2ban SQLite database. db_path: File path to the fail2ban SQLite database.
since: Unix timestamp to filter bans newer than or equal to. since: Unix timestamp to filter bans newer than or equal to.
origin: Optional origin filter. origin: Optional origin filter.
ip_filter: Optional list of IP addresses to restrict the result to.
limit: Optional maximum number of rows to return. limit: Optional maximum number of rows to return.
offset: Optional offset for pagination. offset: Optional offset for pagination.
@@ -142,14 +144,21 @@ async def get_currently_banned(
A ``(records, total)`` tuple. A ``(records, total)`` tuple.
""" """
if ip_filter is not None and len(ip_filter) == 0:
return [], 0
origin_clause, origin_params = _origin_sql_filter(origin) origin_clause, origin_params = _origin_sql_filter(origin)
ip_filter_clause = ""
if ip_filter is not None:
placeholder = ", ".join("?" for _ in ip_filter)
ip_filter_clause = f" AND ip IN ({placeholder})"
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db: async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
async with db.execute( async with db.execute(
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause, "SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause + ip_filter_clause,
(since, *origin_params), (since, *origin_params, *(ip_filter or [])),
) as cur: ) as cur:
count_row = await cur.fetchone() count_row = await cur.fetchone()
total: int = int(count_row[0]) if count_row else 0 total: int = int(count_row[0]) if count_row else 0
@@ -157,9 +166,9 @@ async def get_currently_banned(
query = ( query = (
"SELECT jail, ip, timeofban, bancount, data " "SELECT jail, ip, timeofban, bancount, data "
"FROM bans " "FROM bans "
"WHERE timeofban >= ?" + origin_clause + " ORDER BY timeofban DESC" "WHERE timeofban >= ?" + origin_clause + ip_filter_clause + " ORDER BY timeofban DESC"
) )
params: list[object] = [since, *origin_params] params: list[object] = [since, *origin_params, *(ip_filter or [])]
if limit is not None: if limit is not None:
query += " LIMIT ?" query += " LIMIT ?"
params.append(limit) params.append(limit)

View File

@@ -40,13 +40,16 @@ async def get_archived_history(
db: aiosqlite.Connection, db: aiosqlite.Connection,
since: int | None = None, since: int | None = None,
jail: str | None = None, jail: str | None = None,
ip_filter: str | None = None, ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None, origin: BanOrigin | None = None,
action: str | None = None, action: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 100, page_size: int = 100,
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int]:
"""Return a paginated archived history result set.""" """Return a paginated archived history result set."""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return [], 0
wheres: list[str] = [] wheres: list[str] = []
params: list[object] = [] params: list[object] = []
@@ -59,8 +62,13 @@ async def get_archived_history(
params.append(jail) params.append(jail)
if ip_filter is not None: if ip_filter is not None:
wheres.append("ip LIKE ?") if isinstance(ip_filter, list):
params.append(f"{ip_filter}%") placeholder = ", ".join("?" for _ in ip_filter)
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
if origin == "blocklist": if origin == "blocklist":
wheres.append("jail = ?") wheres.append("jail = ?")
@@ -108,7 +116,7 @@ async def get_all_archived_history(
db: aiosqlite.Connection, db: aiosqlite.Connection,
since: int | None = None, since: int | None = None,
jail: str | None = None, jail: str | None = None,
ip_filter: str | None = None, ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None, origin: BanOrigin | None = None,
action: str | None = None, action: str | None = None,
) -> list[dict]: ) -> list[dict]:

View File

@@ -12,7 +12,7 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp import aiohttp
@@ -83,7 +83,10 @@ async def get_dashboard_bans(
request: Request, request: Request,
_auth: AuthDep, _auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), 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: 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."), page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
origin: BanOrigin | None = Query( origin: BanOrigin | None = Query(
@@ -137,11 +140,18 @@ async def get_bans_by_country(
request: Request, request: Request,
_auth: AuthDep, _auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.",
),
origin: BanOrigin | None = Query( origin: BanOrigin | None = Query(
default=None, default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
), ),
country_code: str | None = Query(
default=None,
description="ISO alpha-2 country code to filter companion rows.",
),
) -> BansByCountryResponse: ) -> BansByCountryResponse:
"""Return ban counts aggregated by ISO country code. """Return ban counts aggregated by ISO country code.
@@ -173,6 +183,7 @@ async def get_bans_by_country(
geo_batch_lookup=geo_service.lookup_batch, geo_batch_lookup=geo_service.lookup_batch,
app_db=request.app.state.db, app_db=request.app.state.db,
origin=origin, origin=origin,
country_code=country_code,
) )
@@ -185,7 +196,10 @@ async def get_ban_trend(
request: Request, request: Request,
_auth: AuthDep, _auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.",
),
origin: BanOrigin | None = Query( origin: BanOrigin | None = Query(
default=None, default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -235,7 +249,10 @@ async def get_bans_by_jail(
request: Request, request: Request,
_auth: AuthDep, _auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.",
),
origin: BanOrigin | None = Query( origin: BanOrigin | None = Query(
default=None, default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",

View File

@@ -15,7 +15,7 @@ Routes
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp import aiohttp
@@ -56,7 +56,7 @@ async def get_history(
default=None, default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
), ),
source: str = Query( source: Literal["fail2ban", "archive"] = Query(
default="fail2ban", default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.", description="Data source: 'fail2ban' or 'archive'.",
), ),

View File

@@ -290,6 +290,7 @@ async def bans_by_country(
geo_enricher: GeoEnricher | None = None, geo_enricher: GeoEnricher | None = None,
app_db: aiosqlite.Connection | None = None, app_db: aiosqlite.Connection | None = None,
origin: BanOrigin | None = None, origin: BanOrigin | None = None,
country_code: str | None = None,
) -> BansByCountryResponse: ) -> BansByCountryResponse:
"""Aggregate ban counts per country for the selected time window. """Aggregate ban counts per country for the selected time window.
@@ -350,16 +351,6 @@ async def bans_by_country(
total = len(all_rows) 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 = {} agg_rows = {}
for row in all_rows: for row in all_rows:
ip = str(row["ip"]) ip = str(row["ip"])
@@ -393,14 +384,6 @@ async def bans_by_country(
origin=origin, origin=origin,
) )
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=since,
origin=origin,
limit=_MAX_COMPANION_BANS,
offset=0,
)
unique_ips = [r.ip for r in agg_rows] unique_ips = [r.ip for r in agg_rows]
geo_map: dict[str, GeoInfo] = {} geo_map: dict[str, GeoInfo] = {}
@@ -434,6 +417,54 @@ async def bans_by_country(
results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips)) results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips))
geo_map = {ip: geo for ip, geo in results if geo is not None} geo_map = {ip: geo for ip, geo in results if geo is not None}
companion_rows: list[dict[str, object] | fail2ban_db_repo.BanRecord]
if country_code is None:
if source == "archive":
companion_rows, _ = await get_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
page=1,
page_size=_MAX_COMPANION_BANS,
)
else:
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=since,
origin=origin,
limit=_MAX_COMPANION_BANS,
offset=0,
)
else:
matched_ips = [
ip
for ip, geo in geo_map.items()
if geo is not None and geo.country_code == country_code
]
if source == "archive":
if matched_ips:
companion_rows = await get_all_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
ip_filter=matched_ips,
)
else:
companion_rows = []
else:
if matched_ips:
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=since,
origin=origin,
ip_filter=matched_ips,
)
else:
companion_rows = []
# Build country aggregation from the SQL-grouped rows. # Build country aggregation from the SQL-grouped rows.
countries: dict[str, int] = {} countries: dict[str, int] = {}
country_names: dict[str, str] = {} country_names: dict[str, str] = {}

View File

@@ -26,7 +26,7 @@ JOB_ID: str = "history_sync"
HISTORY_SYNC_INTERVAL: int = 300 HISTORY_SYNC_INTERVAL: int = 300
#: Backfill window when archive is empty (seconds). #: Backfill window when archive is empty (seconds).
BACKFILL_WINDOW: int = 7 * 86400 BACKFILL_WINDOW: int = 648000
async def _get_last_archive_ts(db) -> int | None: async def _get_last_archive_ts(db) -> int | None:
@@ -50,7 +50,7 @@ async def _run_sync(app: FastAPI) -> None:
log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW) log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW)
per_page = 500 per_page = 500
next_since = last_ts next_since = last_ts + 1
total_synced = 0 total_synced = 0
while True: while True:

View File

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

View File

@@ -80,6 +80,32 @@ async def test_get_currently_banned_filters_and_pagination(tmp_path: Path) -> No
assert records[0].ip == "3.3.3.3" assert records[0].ip == "3.3.3.3"
@pytest.mark.asyncio
async def test_get_currently_banned_filters_by_ip_list(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "1.1.1.1", 10, 1, "{}"),
("jail1", "2.2.2.2", 20, 1, "{}"),
("jail1", "3.3.3.3", 30, 1, "{}"),
],
)
await db.commit()
records, total = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=0,
ip_filter=["2.2.2.2", "3.3.3.3"],
)
assert total == 2
assert len(records) == 2
assert {record.ip for record in records} == {"2.2.2.2", "3.3.3.3"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_ban_counts_by_bucket_ignores_out_of_range_buckets(tmp_path: Path) -> None: async def test_get_ban_counts_by_bucket_ignores_out_of_range_buckets(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db") db_path = str(tmp_path / "fail2ban.db")

View File

@@ -47,6 +47,10 @@ async def test_get_archived_history_filtering_and_pagination(app_db: str) -> Non
assert total == 2 assert total == 2
assert len(rows) == 1 assert len(rows) == 1
rows, total = await get_archived_history(db, ip_filter=["2.2.2.2"])
assert total == 1
assert rows[0]["ip"] == "2.2.2.2"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purge_archived_history(app_db: str) -> None: async def test_purge_archived_history(app_db: str) -> None:

View File

@@ -428,6 +428,15 @@ class TestBansByCountry:
called_range = mock_fn.call_args[0][1] called_range = mock_fn.call_args[0][1]
assert called_range == "7d" 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( async def test_empty_window_returns_empty_response(
self, dashboard_client: AsyncClient self, dashboard_client: AsyncClient
) -> None: ) -> None:
@@ -513,6 +522,19 @@ class TestDashboardBansOriginField:
assert mock_fn.call_args[1]["source"] == "archive" assert mock_fn.call_args[1]["source"] == "archive"
async def test_bans_by_country_country_code_forwarded(
self, dashboard_client: AsyncClient
) -> None:
"""The ``country_code`` 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?country_code=DE"
)
_, kwargs = mock_fn.call_args
assert kwargs.get("country_code") == "DE"
async def test_blocklist_origin_serialised_correctly( async def test_blocklist_origin_serialised_correctly(
self, dashboard_client: AsyncClient self, dashboard_client: AsyncClient
) -> None: ) -> None:
@@ -722,6 +744,15 @@ class TestBanTrend:
) )
assert response.status_code == 422 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: async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
"""Empty bucket list is serialised correctly.""" """Empty bucket list is serialised correctly."""
from app.models.ban import BanTrendResponse from app.models.ban import BanTrendResponse
@@ -857,6 +888,15 @@ class TestBansByJail:
) )
assert response.status_code == 422 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: async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None:
"""Empty jails list is serialised correctly.""" """Empty jails list is serialised correctly."""
from app.models.ban import BansByJailResponse from app.models.ban import BansByJailResponse

View File

@@ -654,6 +654,54 @@ class TestOriginFilter:
assert result.total == 3 assert result.total == 3
async def test_bans_by_country_country_code_returns_all_matched_rows(
self, tmp_path: Path
) -> None:
"""``bans_by_country`` returns all companion rows for the selected country."""
path = str(tmp_path / "fail2ban_country_filter.sqlite3")
rows = [
{
"jail": "sshd",
"ip": "10.0.0.1",
"timeofban": _ONE_HOUR_AGO - i,
"bantime": 3600,
"bancount": 1,
"data": {"matches": ["failed login"]},
}
for i in range(205)
]
await _create_f2b_db(path, rows)
from app.services import geo_service
geo_service._cache["10.0.0.1"] = geo_service.GeoInfo(
country_code="DE",
country_name="Germany",
asn=None,
org=None,
)
with patch(
"app.services.ban_service.get_fail2ban_db_path",
new=AsyncMock(return_value=path),
), patch(
"app.services.ban_service.asyncio.create_task"
) as mock_create_task:
result = await ban_service.bans_by_country(
"/fake/sock",
"24h",
country_code="DE",
http_session=AsyncMock(),
geo_cache_lookup=geo_service.lookup_cached_only,
)
mock_create_task.assert_not_called()
assert result.total == 205
assert len(result.bans) == 205
assert all(b.country_code == "DE" for b in result.bans)
geo_service.clear_cache()
async def test_bans_by_country_source_archive_reads_archive( async def test_bans_by_country_source_archive_reads_archive(
self, app_db_with_archive: aiosqlite.Connection self, app_db_with_archive: aiosqlite.Connection
) -> None: ) -> None:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock, patch
from app.tasks import history_sync from app.tasks import history_sync
@@ -27,3 +27,33 @@ class TestHistorySyncTask:
called_args, called_kwargs = fake_scheduler.add_job.call_args called_args, called_kwargs = fake_scheduler.add_job.call_args
assert called_kwargs["id"] == history_sync.JOB_ID assert called_kwargs["id"] == history_sync.JOB_ID
assert called_kwargs["kwargs"]["app"] == app 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

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

View File

@@ -1,30 +1,33 @@
{ {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.14", "version": "0.9.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.14", "version": "0.9.18",
"dependencies": { "dependencies": {
"@fluentui/react-components": "^9.55.0", "@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257", "@fluentui/react-icons": "^2.0.257",
"@types/react-simple-maps": "^3.0.6", "d3-geo": "^3.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.27.0", "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": { "devDependencies": {
"@eslint/js": "^9.13.0", "@eslint/js": "^9.13.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3-geo": "^3.1.0",
"@types/node": "^25.3.2", "@types/node": "^25.3.2",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/topojson-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.13.0", "@typescript-eslint/parser": "^8.13.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
@@ -3565,23 +3568,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-geo": { "node_modules/@types/d3-geo": {
"version": "2.0.7", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==", "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/geojson": "*" "@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": { "node_modules/@types/d3-path": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
@@ -3597,12 +3592,6 @@
"@types/d3-time": "*" "@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": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -3624,16 +3613,6 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "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": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3652,6 +3631,7 @@
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
@@ -3696,16 +3676,25 @@
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
}, },
"node_modules/@types/react-simple-maps": { "node_modules/@types/topojson-client": {
"version": "3.0.6", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==", "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/d3-geo": "^2",
"@types/d3-zoom": "^2",
"@types/geojson": "*", "@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": { "node_modules/@types/use-sync-external-store": {
@@ -4476,28 +4465,6 @@
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
"license": "BSD-3-Clause" "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": { "node_modules/d3-format": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -4508,12 +4475,15 @@
} }
}, },
"node_modules/d3-geo": { "node_modules/d3-geo": {
"version": "2.0.2", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "BSD-3-Clause", "license": "ISC",
"dependencies": { "dependencies": {
"d3-array": "^2.5.0" "d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
} }
}, },
"node_modules/d3-interpolate": { "node_modules/d3-interpolate": {
@@ -4550,12 +4520,6 @@
"node": ">=12" "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": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -4592,41 +4556,6 @@
"node": ">=12" "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": { "node_modules/data-urls": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5745,16 +5674,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -5982,18 +5901,6 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6110,23 +6017,6 @@
"react-dom": ">=16.8" "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": { "node_modules/recharts": {
"version": "3.8.0", "version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
@@ -7516,6 +7406,12 @@
"node": ">=0.10.0" "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": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import { ENDPOINTS } from "./endpoints";
import { fetchBansByCountry } from "./map";
import { get } from "./client";
vi.mock("./client", () => ({
get: vi.fn(),
}));
const mockedGet = get as Mock;
describe("fetchBansByCountry", () => {
beforeEach(() => {
mockedGet.mockReset();
mockedGet.mockResolvedValue({ countries: {}, country_names: {}, bans: [], total: 0 });
});
it("appends country_code when provided", async () => {
await fetchBansByCountry("24h", "all", "fail2ban", "US");
expect(get).toHaveBeenCalledWith(
`${ENDPOINTS.dashboardBansByCountry}?range=24h&country_code=US`
);
});
it("does not append country_code when undefined", async () => {
await fetchBansByCountry("24h", "all", "fail2ban");
expect(get).toHaveBeenCalledWith(
`${ENDPOINTS.dashboardBansByCountry}?range=24h`
);
});
});

View File

@@ -17,10 +17,18 @@ import type { BanOriginFilter } from "../types/ban";
export async function fetchBansByCountry( export async function fetchBansByCountry(
range: TimeRange = "24h", range: TimeRange = "24h",
origin: BanOriginFilter = "all", origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
countryCode?: string,
): Promise<BansByCountryResponse> { ): Promise<BansByCountryResponse> {
const params = new URLSearchParams({ range }); const params = new URLSearchParams({ range });
if (origin !== "all") { if (origin !== "all") {
params.set("origin", origin); params.set("origin", origin);
} }
if (source !== "fail2ban") {
params.set("source", source);
}
if (countryCode) {
params.set("country_code", countryCode);
}
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`); 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. * Changing this value triggers a re-fetch and resets to page 1.
*/ */
origin?: BanOriginFilter; 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.timeRange - Active time-range preset from the parent page.
* @param props.origin - Active origin filter 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 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); const banColumns = buildBanColumns(styles);

View File

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

View File

@@ -8,6 +8,7 @@
import { import {
Divider, Divider,
Input,
Text, Text,
ToggleButton, ToggleButton,
Toolbar, Toolbar,
@@ -35,6 +36,14 @@ export interface DashboardFilterBarProps {
originFilter: BanOriginFilter; originFilter: BanOriginFilter;
/** Called when the user selects a different origin filter. */ /** Called when the user selects a different origin filter. */
onOriginFilterChange: (value: BanOriginFilter) => void; 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, onTimeRangeChange,
originFilter, originFilter,
onOriginFilterChange, onOriginFilterChange,
jail,
onJailChange,
ip,
onIpChange,
}: DashboardFilterBarProps): React.JSX.Element { }: DashboardFilterBarProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles(); const cardStyles = useCardStyles();
@@ -146,6 +159,48 @@ export function DashboardFilterBar({
))} ))}
</Toolbar> </Toolbar>
</div> </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> </div>
); );
} }

View File

@@ -1,31 +1,42 @@
/** /**
* WorldMap — SVG world map showing per-country ban counts. * WorldMap — SVG world map showing per-country ban counts.
* *
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from * Uses a local TopoJSON bundle and d3-geo for projection, path generation,
* jsDelivr CDN. For each country that has bans in the selected time window, * and native SVG pan/zoom behaviour.
* the total count is displayed inside the country's borders. Clicking a
* country filters the companion table.
*/ */
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useCallback, useState } from "react"; import {
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button, makeStyles, tokens } from "@fluentui/react-components"; 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 { useCardStyles } from "../theme/commonStyles";
import type { GeoPermissibleObjects } from "d3-geo";
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
import { getBanCountColor } from "../utils/mapColors"; import { getBanCountColor } from "../utils/mapColors";
// --------------------------------------------------------------------------- const MAP_WIDTH = 800;
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only) const MAP_HEIGHT = 400;
// --------------------------------------------------------------------------- const MIN_ZOOM = 1;
const MAX_ZOOM = 8;
const GEO_URL = const ZOOM_STEP = 0.5;
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; const PAN_THRESHOLD = 3;
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({ const useStyles = makeStyles({
mapWrapper: { mapWrapper: {
@@ -33,6 +44,25 @@ const useStyles = makeStyles({
position: "relative", position: "relative",
overflow: "hidden", 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: { countLabel: {
fontSize: "9px", fontSize: "9px",
fontWeight: "600", fontWeight: "600",
@@ -73,194 +103,21 @@ const useStyles = makeStyles({
}, },
}); });
// --------------------------------------------------------------------------- type TopoJsonTopology = Topology & {
// GeoLayer — must be rendered inside ComposableMap to access map context objects: {
// --------------------------------------------------------------------------- countries: TopoGeometryCollection;
};
};
interface GeoLayerProps { type TooltipState = {
countries: Record<string, number>; cc: string;
countryNames?: Record<string, string>; count: number;
selectedCountry: string | null; name: string;
onSelectCountry: (cc: string | null) => void; x: number;
thresholdLow: number; y: number;
thresholdMedium: number; } | null;
thresholdHigh: number;
}
function GeoLayer({ interface WorldMapProps {
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<
| {
cc: string;
count: number;
name: string;
x: number;
y: number;
}
| 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" }}>
<Geography
geography={geo}
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);
}}
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 {
/** ISO alpha-2 country code → ban count. */ /** ISO alpha-2 country code → ban count. */
countries: Record<string, number>; countries: Record<string, number>;
/** Optional mapping from country code to display name. */ /** Optional mapping from country code to display name. */
@@ -288,21 +145,150 @@ export function WorldMap({
}: WorldMapProps): React.JSX.Element { }: WorldMapProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles(); 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 [center, setCenter] = useState<[number, number]>([0, 0]);
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
const [tooltip, setTooltip] = useState<TooltipState>(null);
const handleZoomIn = (): void => { const zoomRef = useRef<number>(zoom);
setZoom((z) => Math.min(z + 0.5, 8)); 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);
const handleZoomOut = (): void => { useEffect(() => {
setZoom((z) => Math.max(z - 0.5, 1)); zoomRef.current = zoom;
}; }, [zoom]);
const handleResetView = (): void => { useEffect(() => {
setZoom(1); 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],
);
/** SVG-level click handler — paths never receive click when pointer capture
* is active on the SVG, so we resolve the target via the data-cc attribute. */
const handleSvgClick = useCallback((event: React.MouseEvent<SVGSVGElement>) => {
const target = (event.target as Element).closest('[data-cc]');
const cc = target?.getAttribute('data-cc') ?? null;
if (cc) handleCountrySelect(cc);
}, [handleCountrySelect]);
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
if (event.button !== 0) return;
dragStateRef.current = {
active: true,
startX: event.clientX,
startY: event.clientY,
startCenter: centerRef.current,
moved: false,
};
clickSuppressedRef.current = false;
}, []);
const handlePointerMove = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
const drag = dragStateRef.current;
if (!drag?.active) return;
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;
event.currentTarget.setPointerCapture(event.pointerId);
}
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]); setCenter([0, 0]);
}; }, []);
return ( return (
<div <div
@@ -310,13 +296,12 @@ export function WorldMap({
role="img" role="img"
aria-label="World map showing banned IP counts by country. Click a country to filter the table below." aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
> >
{/* Zoom controls */}
<div className={styles.zoomControls}> <div className={styles.zoomControls}>
<Button <Button
appearance="secondary" appearance="secondary"
size="small" size="small"
onClick={handleZoomIn} onClick={handleZoomIn}
disabled={zoom >= 8} disabled={zoom >= MAX_ZOOM}
title="Zoom in" title="Zoom in"
aria-label="Zoom in" aria-label="Zoom in"
> >
@@ -326,7 +311,7 @@ export function WorldMap({
appearance="secondary" appearance="secondary"
size="small" size="small"
onClick={handleZoomOut} onClick={handleZoomOut}
disabled={zoom <= 1} disabled={zoom <= MIN_ZOOM}
title="Zoom out" title="Zoom out"
aria-label="Zoom out" aria-label="Zoom out"
> >
@@ -336,7 +321,7 @@ export function WorldMap({
appearance="secondary" appearance="secondary"
size="small" size="small"
onClick={handleResetView} onClick={handleResetView}
disabled={zoom === 1 && center[0] === 0 && center[1] === 0} disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
title="Reset view" title="Reset view"
aria-label="Reset view" aria-label="Reset view"
> >
@@ -344,34 +329,111 @@ export function WorldMap({
</Button> </Button>
</div> </div>
<ComposableMap <svg
projection="geoMercator" className={styles.svg}
projectionConfig={{ scale: 130, center: [10, 20] }} viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
width={800} role="img"
height={400} aria-label="World map showing banned IP counts by country."
style={{ width: "100%", height: "auto" }} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
onClick={handleSvgClick}
> >
<ZoomableGroup <g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
zoom={zoom} {countryFeatures.map((featureItem) => {
center={center} const rawId = featureItem.id;
onMoveEnd={({ zoom: newZoom, coordinates }): void => { const numericId = String(Number(rawId));
setZoom(newZoom); const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
setCenter(coordinates); const count = cc !== null ? countries[cc] ?? 0 : 0;
}} const isSelected = cc !== null && selectedCountry === cc;
minZoom={1} const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
maxZoom={8} const pathString = pathGenerator(featureItem) ?? "";
> if (!pathString) {
<GeoLayer return null;
countries={countries} }
countryNames={countryNames}
selectedCountry={selectedCountry} return (
onSelectCountry={onSelectCountry} <g key={String(rawId)}>
thresholdLow={thresholdLow} <path
thresholdMedium={thresholdMedium} d={pathString}
thresholdHigh={thresholdHigh} data-cc={cc ?? undefined}
/> role={cc ? "button" : undefined}
</ZoomableGroup> tabIndex={cc ? 0 : undefined}
</ComposableMap> 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);
}}
/>
</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> </div>
); );
} }

View File

@@ -125,4 +125,47 @@ describe("DashboardFilterBar", () => {
expect(onTimeRangeChange).toHaveBeenCalledOnce(); expect(onTimeRangeChange).toHaveBeenCalledOnce();
expect(onTimeRangeChange).toHaveBeenCalledWith("24h"); 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 { fireEvent, render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { FluentProvider, webLightTheme } from "@fluentui/react-components";
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry. vi.mock(
vi.mock("react-simple-maps", () => ({ "world-atlas/countries-110m.json",
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, () => ({
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, default: {
Geography: ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => <g {...props}>{children}</g>, type: "Topology",
useGeographies: () => ({ objects: {
geographies: [{ rsmKey: "geo-1", id: 840 }], countries: {
path: { centroid: () => [10, 10] }, 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"; import { WorldMap } from "../WorldMap";
@@ -34,19 +49,20 @@ describe("WorldMap", () => {
</FluentProvider>, </FluentProvider>,
); );
// Tooltip should not be present initially
expect(screen.queryByRole("tooltip")).toBeNull(); expect(screen.queryByRole("tooltip")).toBeNull();
// Country map area is exposed as an accessible button with an accurate label
const countryButton = screen.getByRole("button", { name: "US: 42 bans" }); const countryButton = screen.getByRole("button", { name: "US: 42 bans" });
expect(countryButton).toBeInTheDocument(); 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 }); fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
const tooltip = screen.getByRole("tooltip"); const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("United States"); expect(tooltip).toHaveTextContent("United States");
expect(tooltip).toHaveTextContent("42 bans"); expect(tooltip).toHaveTextContent("42 bans");
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
fireEvent.mouseLeave(countryButton); fireEvent.mouseLeave(countryButton);
expect(screen.queryByRole("tooltip")).toBeNull(); expect(screen.queryByRole("tooltip")).toBeNull();

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,8 @@ export interface UseMapDataResult {
export function useMapData( export function useMapData(
range: TimeRange = "24h", range: TimeRange = "24h",
origin: BanOriginFilter = "all", origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
countryCode?: string,
): UseMapDataResult { ): UseMapDataResult {
const [data, setData] = useState<BansByCountryResponse | null>(null); const [data, setData] = useState<BansByCountryResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -64,7 +66,7 @@ export function useMapData(
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = new AbortController(); abortRef.current = new AbortController();
fetchBansByCountry(range, origin) fetchBansByCountry(range, origin, source, countryCode)
.then((resp) => { .then((resp) => {
setData(resp); setData(resp);
}) })
@@ -75,7 +77,7 @@ export function useMapData(
setLoading(false); setLoading(false);
}); });
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
}, [range, origin]); }, [range, origin, source, countryCode]);
useEffect((): (() => void) => { useEffect((): (() => void) => {
load(); load();

View File

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

View File

@@ -16,7 +16,6 @@ import {
DataGridHeader, DataGridHeader,
DataGridHeaderCell, DataGridHeaderCell,
DataGridRow, DataGridRow,
Input,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
Spinner, Spinner,
@@ -82,11 +81,6 @@ const useStyles = makeStyles({
gap: tokens.spacingHorizontalM, gap: tokens.spacingHorizontalM,
flexWrap: "wrap", flexWrap: "wrap",
}, },
filterLabel: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
tableWrapper: { tableWrapper: {
overflow: "auto", overflow: "auto",
borderRadius: tokens.borderRadiusMedium, borderRadius: tokens.borderRadiusMedium,
@@ -149,6 +143,7 @@ function areHistoryQueriesEqual(
a.origin === b.origin && a.origin === b.origin &&
a.jail === b.jail && a.jail === b.jail &&
a.ip === b.ip && a.ip === b.ip &&
a.source === b.source &&
a.page === b.page && a.page === b.page &&
a.page_size === b.page_size a.page_size === b.page_size
); );
@@ -390,14 +385,14 @@ function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element {
export function HistoryPage(): React.JSX.Element { export function HistoryPage(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles();
// Filter state // Filter state
const [range, setRange] = useState<TimeRange>("24h"); const [range, setRange] = useState<TimeRange>("7d");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all"); const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [jailFilter, setJailFilter] = useState(""); const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState(""); const [ipFilter, setIpFilter] = useState("");
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({ const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
source: "archive",
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}); });
@@ -413,6 +408,7 @@ export function HistoryPage(): React.JSX.Element {
origin: originFilter !== "all" ? originFilter : undefined, origin: originFilter !== "all" ? originFilter : undefined,
jail: jailFilter.trim() || undefined, jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined, ip: ipFilter.trim() || undefined,
source: "archive",
page: 1, page: 1,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}; };
@@ -483,51 +479,17 @@ export function HistoryPage(): React.JSX.Element {
onOriginFilterChange={(value) => { onOriginFilterChange={(value) => {
setOriginFilter(value); setOriginFilter(value);
}} }}
jail={jailFilter}
onJailChange={(value) => {
setJailFilter(value);
}}
ip={ipFilter}
onIpChange={(value) => {
setIpFilter(value);
}}
/> />
<div className={`${styles.filterLabel} ${cardStyles.card}`}>
<Text size={200}>Jail</Text>
<Input
placeholder="e.g. sshd"
value={jailFilter}
onChange={(_ev, data): void => {
setJailFilter(data.value);
}}
size="small"
/>
</div>
<div className={`${styles.filterLabel} ${cardStyles.card}`}>
<Text size={200}>IP Address</Text>
<Input
placeholder="e.g. 192.168"
value={ipFilter}
onChange={(_ev, data): void => {
setIpFilter(data.value);
}}
size="small"
/>
</div>
</div> </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 */} {/* Summary */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}

View File

@@ -64,6 +64,13 @@ const useStyles = makeStyles({
borderRadius: tokens.borderRadiusMedium, borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`, border: `1px solid ${tokens.colorNeutralStroke1}`,
}, },
stickyHeaderCell: {
position: "sticky",
top: 0,
zIndex: 1,
backgroundColor: tokens.colorNeutralBackground1,
boxShadow: `0 1px 0 ${tokens.colorNeutralStroke2}`,
},
filterBar: { filterBar: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -81,6 +88,9 @@ const useStyles = makeStyles({
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
borderTop: `1px solid ${tokens.colorNeutralStroke2}`, borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
backgroundColor: tokens.colorNeutralBackground2, backgroundColor: tokens.colorNeutralBackground2,
position: "sticky",
bottom: 0,
zIndex: 1,
}, },
}); });
@@ -98,8 +108,17 @@ export function MapPage(): React.JSX.Element {
const PAGE_SIZE_OPTIONS = [25, 50, 100] as const; const PAGE_SIZE_OPTIONS = [25, 50, 100] as const;
const source = range === "24h" ? "fail2ban" : "archive";
const { countries, countryNames, bans, total, loading, error, refresh } = const { countries, countryNames, bans, total, loading, error, refresh } =
useMapData(range, originFilter); useMapData(range, originFilter, source, selectedCountry ?? undefined);
// True after the first successful data load — keeps the map mounted
// during subsequent re-fetches so country selection gives instant feedback.
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
useEffect(() => {
if (!loading && !error) setHasLoadedOnce(true);
}, [loading, error]);
const { const {
thresholds: mapThresholds, thresholds: mapThresholds,
@@ -183,7 +202,8 @@ export function MapPage(): React.JSX.Element {
</MessageBar> </MessageBar>
)} )}
{loading && !error && ( {/* Initial load spinner — only shown before the first data arrives. */}
{loading && !error && !hasLoadedOnce && (
<div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}> <div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}>
<Spinner label="Loading map data…" /> <Spinner label="Loading map data…" />
</div> </div>
@@ -191,8 +211,10 @@ export function MapPage(): React.JSX.Element {
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{/* World map */} {/* World map */}
{/* Keep the map mounted after first load so clicking a country gives */}
{/* immediate visual feedback before the filtered data arrives. */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{!loading && !error && ( {!error && hasLoadedOnce && (
<WorldMap <WorldMap
countries={countries} countries={countries}
countryNames={countryNames} countryNames={countryNames}
@@ -230,28 +252,31 @@ export function MapPage(): React.JSX.Element {
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{/* Summary line */} {/* Summary line */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{!loading && !error && ( {!error && hasLoadedOnce && (
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}> <div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
{String(total)} total ban{total !== 1 ? "s" : ""} in the selected period <Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
{" · "} {String(total)} total ban{total !== 1 ? "s" : ""} in the selected period
{String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected {" · "}
</Text> {String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected
</Text>
{loading && <Spinner size="tiny" />}
</div>
)} )}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{/* Companion bans table */} {/* Companion bans table */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{!loading && !error && ( {!error && hasLoadedOnce && (
<div className={styles.tableWrapper}> <div className={styles.tableWrapper} style={{ opacity: loading ? 0.5 : 1, transition: "opacity 150ms" }}>
<Table size="small" aria-label="Bans list"> <Table size="small" aria-label="Bans list">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>IP Address</TableHeaderCell> <TableHeaderCell className={styles.stickyHeaderCell}>IP Address</TableHeaderCell>
<TableHeaderCell>Jail</TableHeaderCell> <TableHeaderCell className={styles.stickyHeaderCell}>Jail</TableHeaderCell>
<TableHeaderCell>Banned At</TableHeaderCell> <TableHeaderCell className={styles.stickyHeaderCell}>Banned At</TableHeaderCell>
<TableHeaderCell>Country</TableHeaderCell> <TableHeaderCell className={styles.stickyHeaderCell}>Country</TableHeaderCell>
<TableHeaderCell>Origin</TableHeaderCell> <TableHeaderCell className={styles.stickyHeaderCell}>Origin</TableHeaderCell>
<TableHeaderCell>Times Banned</TableHeaderCell> <TableHeaderCell className={styles.stickyHeaderCell}>Times Banned</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>

View File

@@ -50,7 +50,8 @@ describe("HistoryPage", () => {
// Initial load should include the auto-applied default query. // Initial load should include the auto-applied default query.
await waitFor(() => { await waitFor(() => {
expect(lastQuery).toEqual({ expect(lastQuery).toEqual({
range: "24h", range: "7d",
source: "archive",
origin: undefined, origin: undefined,
jail: undefined, jail: undefined,
ip: undefined, ip: undefined,

View File

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

View File

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