Stage 8: world map view — backend endpoint, map component, map page

- BansByCountryResponse model added to ban.py
- bans_by_country() service: parallel geo lookup via asyncio.gather,
  aggregation by ISO alpha-2 country code (up to 2 000 bans)
- GET /api/dashboard/bans/by-country endpoint in dashboard router
- 290 tests pass (5 new), ruff + mypy clean (44 files)
- isoNumericToAlpha2.ts: 249-entry ISO numeric → alpha-2 static map
- types/map.ts, api/map.ts, hooks/useMapData.ts created
- WorldMap.tsx: react-simple-maps Mercator SVG map, per-country ban
  count overlay, colour intensity scaling, country click filtering,
  GeoLayer nested-component pattern for useGeographies context
- MapPage.tsx: time-range selector, WorldMap, country filter info bar,
  summary line, companion FluentUI Table with country filter
- Frontend tsc + ESLint clean (0 errors/warnings)
This commit is contained in:
2026-03-01 14:53:49 +01:00
parent 7f81f0614b
commit 54313fd3e0
13 changed files with 1343 additions and 20 deletions

View File

@@ -191,3 +191,29 @@ class AccessListResponse(BaseModel):
total: int = Field(..., ge=0)
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
class BansByCountryResponse(BaseModel):
"""Response for the bans-by-country aggregation endpoint.
Contains a per-country ban count, a human-readable country name map, and
the full (un-paginated) ban list for the selected time window so the
frontend can render both the world map and its companion table from a
single request.
"""
model_config = ConfigDict(strict=True)
countries: dict[str, int] = Field(
default_factory=dict,
description="ISO 3166-1 alpha-2 country code → ban count.",
)
country_names: dict[str, str] = Field(
default_factory=dict,
description="ISO 3166-1 alpha-2 country code → human-readable country name.",
)
bans: list[DashboardBanItem] = Field(
default_factory=list,
description="All bans in the selected time window (up to the server limit).",
)
total: int = Field(..., ge=0, description="Total ban count in the window.")

View File

@@ -20,6 +20,7 @@ from fastapi import APIRouter, Query, Request
from app.dependencies import AuthDep
from app.models.ban import (
AccessListResponse,
BansByCountryResponse,
DashboardBanListResponse,
TimeRange,
)
@@ -156,3 +157,41 @@ async def get_dashboard_accesses(
geo_enricher=_enricher,
)
@router.get(
"/bans/by-country",
response_model=BansByCountryResponse,
summary="Return ban counts aggregated by country",
)
async def get_bans_by_country(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
) -> BansByCountryResponse:
"""Return ban counts aggregated by ISO country code.
Fetches up to 2 000 ban records in the selected time window, enriches
every record with geo data, and returns a ``{country_code: count}`` map
plus the full enriched ban list for the companion access table.
Args:
request: The incoming request.
_auth: Validated session dependency.
range: Time-range preset.
Returns:
:class:`~app.models.ban.BansByCountryResponse` with per-country
aggregation and the full ban list.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
async def _enricher(ip: str) -> geo_service.GeoInfo | None:
return await geo_service.lookup(ip, http_session)
return await ban_service.bans_by_country(
socket_path,
range,
geo_enricher=_enricher,
)

View File

@@ -21,6 +21,7 @@ from app.models.ban import (
TIME_RANGE_SECONDS,
AccessListItem,
AccessListResponse,
BansByCountryResponse,
DashboardBanItem,
DashboardBanListResponse,
TimeRange,
@@ -323,3 +324,112 @@ async def list_accesses(
page=page,
page_size=effective_page_size,
)
# ---------------------------------------------------------------------------
# bans_by_country
# ---------------------------------------------------------------------------
#: Maximum bans fetched for aggregation (guard against huge databases).
_MAX_GEO_BANS: int = 2_000
async def bans_by_country(
socket_path: str,
range_: TimeRange,
geo_enricher: Any | None = None,
) -> BansByCountryResponse:
"""Aggregate ban counts per country for the selected time window.
Fetches up to ``_MAX_GEO_BANS`` ban records from the fail2ban database,
enriches them with geo data, and returns a ``{country_code: count}`` map
alongside the enriched ban list for the companion access table.
Args:
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset.
geo_enricher: Optional async ``(ip) -> GeoInfo | None`` callable.
Returns:
:class:`~app.models.ban.BansByCountryResponse` with per-country
aggregation and the full ban list.
"""
import asyncio
since: int = _since_unix(range_)
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("ban_service_bans_by_country", db_path=db_path, since=since, range=range_)
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
f2b_db.row_factory = aiosqlite.Row
async with f2b_db.execute(
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?",
(since,),
) as cur:
count_row = await cur.fetchone()
total: int = int(count_row[0]) if count_row else 0
async with f2b_db.execute(
"SELECT jail, ip, timeofban, bancount, data "
"FROM bans "
"WHERE timeofban >= ? "
"ORDER BY timeofban DESC "
"LIMIT ?",
(since, _MAX_GEO_BANS),
) as cur:
rows = await cur.fetchall()
# Geo-enrich unique IPs in parallel.
unique_ips: list[str] = list({str(r["ip"]) for r in rows})
geo_map: dict[str, Any] = {}
if geo_enricher is not None and unique_ips:
async def _safe_lookup(ip: str) -> tuple[str, Any]:
try:
return ip, await geo_enricher(ip)
except Exception: # noqa: BLE001
log.warning("ban_service_geo_lookup_failed", ip=ip)
return ip, None
results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips))
geo_map = dict(results)
# Build ban items and aggregate country counts.
countries: dict[str, int] = {}
country_names: dict[str, str] = {}
bans: list[DashboardBanItem] = []
for row in rows:
ip = str(row["ip"])
geo = geo_map.get(ip)
cc: str | None = geo.country_code if geo else None
cn: str | None = geo.country_name if geo else None
asn: str | None = geo.asn if geo else None
org: str | None = geo.org if geo else None
matches, _ = _parse_data_json(row["data"])
bans.append(
DashboardBanItem(
ip=ip,
jail=str(row["jail"]),
banned_at=_ts_to_iso(int(row["timeofban"])),
service=matches[0] if matches else None,
country_code=cc,
country_name=cn,
asn=asn,
org=org,
ban_count=int(row["bancount"]),
)
)
if cc:
countries[cc] = countries.get(cc, 0) + 1
if cn and cc not in country_names:
country_names[cc] = cn
return BansByCountryResponse(
countries=countries,
country_names=country_names,
bans=bans,
total=total,
)

View File

@@ -389,3 +389,121 @@ class TestDashboardAccesses:
called_range = mock_list.call_args[0][1]
assert called_range == "24h"
# ---------------------------------------------------------------------------
# Bans by country endpoint
# ---------------------------------------------------------------------------
def _make_bans_by_country_response() -> object:
"""Build a stub BansByCountryResponse."""
from app.models.ban import BansByCountryResponse
items = [
DashboardBanItem(
ip="1.2.3.4",
jail="sshd",
banned_at="2026-03-01T10:00:00+00:00",
service=None,
country_code="DE",
country_name="Germany",
asn="AS3320",
org="Telekom",
ban_count=1,
),
DashboardBanItem(
ip="5.6.7.8",
jail="sshd",
banned_at="2026-03-01T10:05:00+00:00",
service=None,
country_code="US",
country_name="United States",
asn="AS15169",
org="Google LLC",
ban_count=2,
),
]
return BansByCountryResponse(
countries={"DE": 1, "US": 1},
country_names={"DE": "Germany", "US": "United States"},
bans=items,
total=2,
)
@pytest.mark.anyio
class TestBansByCountry:
"""GET /api/dashboard/bans/by-country."""
async def test_returns_200_when_authenticated(
self, dashboard_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
with patch(
"app.routers.dashboard.ban_service.bans_by_country",
new=AsyncMock(return_value=_make_bans_by_country_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-country")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
self, client: AsyncClient
) -> None:
"""Unauthenticated request returns HTTP 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/dashboard/bans/by-country")
assert response.status_code == 401
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
"""Response body contains countries, country_names, bans, total."""
with patch(
"app.routers.dashboard.ban_service.bans_by_country",
new=AsyncMock(return_value=_make_bans_by_country_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-country")
body = response.json()
assert "countries" in body
assert "country_names" in body
assert "bans" in body
assert "total" in body
assert body["total"] == 2
assert body["countries"]["DE"] == 1
assert body["countries"]["US"] == 1
assert body["country_names"]["DE"] == "Germany"
async def test_accepts_time_range_param(
self, dashboard_client: AsyncClient
) -> None:
"""The range query parameter is forwarded to ban_service."""
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?range=7d")
called_range = mock_fn.call_args[0][1]
assert called_range == "7d"
async def test_empty_window_returns_empty_response(
self, dashboard_client: AsyncClient
) -> None:
"""Empty time range returns empty countries dict and bans list."""
from app.models.ban import BansByCountryResponse
empty = BansByCountryResponse(
countries={},
country_names={},
bans=[],
total=0,
)
with patch(
"app.routers.dashboard.ban_service.bans_by_country",
new=AsyncMock(return_value=empty),
):
response = await dashboard_client.get("/api/dashboard/bans/by-country")
body = response.json()
assert body["total"] == 0
assert body["countries"] == {}
assert body["bans"] == []