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:
@@ -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.")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user