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

@@ -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,
)