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:
@@ -278,25 +278,25 @@ Backend linters: `ruff check` clean, `mypy app/` clean (44 files). Frontend: `ts
|
||||
|
||||
---
|
||||
|
||||
## Stage 8 — World Map View
|
||||
## Stage 8 — World Map View ✅ DONE
|
||||
|
||||
A geographical visualisation of ban activity. This stage depends on the geo service from Stage 5 and the ban data pipeline from Stage 5.
|
||||
|
||||
### 8.1 Implement the map data endpoint
|
||||
### 8.1 Implement the map data endpoint ✅ DONE
|
||||
|
||||
Add `GET /api/dashboard/bans/by-country` to the dashboard router. It accepts the same time-range parameter as the ban list endpoint. It queries bans in the selected window, enriches them with geo data, and returns an aggregated count per country (ISO country code → ban count). Also return the full ban list so the frontend can display the companion table. See [Features.md § 4](Features.md).
|
||||
Added `GET /api/dashboard/bans/by-country` to `backend/app/routers/dashboard.py`. Added `BansByCountryResponse` model (`countries: dict[str, int]`, `country_names: dict[str, str]`, `bans: list[DashboardBanItem]`, `total: int`) to `backend/app/models/ban.py`. Implemented `bans_by_country()` in `backend/app/services/ban_service.py` — fetches up to 2 000 bans from the window, deduplicates IPs, resolves geo concurrently with `asyncio.gather`, then aggregates by ISO alpha-2 country code.
|
||||
|
||||
### 8.2 Build the world map component (frontend)
|
||||
### 8.2 Build the world map component (frontend) ✅ DONE
|
||||
|
||||
Create `frontend/src/components/WorldMap.tsx`. Render a full world map with country outlines only — no fill colours, no satellite imagery. For each country with bans, display the ban count centred inside the country's borders. Countries with zero bans remain blank. Consider a lightweight SVG-based map library or a TopoJSON/GeoJSON world outline rendered with D3 or a comparable tool. The map must be interactive: clicking a country filters the companion access list. Include the same time-range selector as the dashboard. See [Features.md § 4](Features.md).
|
||||
Created `frontend/src/data/isoNumericToAlpha2.ts` — static 249-entry mapping of ISO 3166-1 numeric codes (as used in world-atlas TopoJSON `geo.id`) to alpha-2 codes. Created `frontend/src/components/WorldMap.tsx` using `react-simple-maps@3.0.0`. Renders a Mercator SVG world map with per-country colour intensity scaled from the maximum ban count. Countries with bans show the count in text. Selected country highlighted with brand accent colour. Uses a nested `GeoLayer` component (inside `ComposableMap`) to call `useGeographies` within the map context. Clicking a country toggles its filter; clicking again clears it.
|
||||
|
||||
### 8.3 Build the map page (frontend)
|
||||
### 8.3 Build the map page (frontend) ✅ DONE
|
||||
|
||||
Create `frontend/src/pages/MapPage.tsx`. Compose the time-range selector, the `WorldMap` component, and an access list table below. When a country is selected on the map, the table filters to show only entries from that country. Clicking the map background (or a "Clear filter" button) removes the country filter. Create `frontend/src/hooks/useMapData.ts` to fetch and manage the aggregated data. See [Features.md § 4](Features.md).
|
||||
Replaced placeholder `frontend/src/pages/MapPage.tsx` with a full implementation. Includes a time-range `Select` (24h/7d/30d/365d), the `WorldMap` component, an active-filter info bar showing the selected country name and ban count with a "Clear filter" button, a summary line (total bans + number of countries), and a companion FluentUI `Table` filtered by selected country (columns: IP, Jail, Banned At, Country, Times Banned). Created `frontend/src/hooks/useMapData.ts` and `frontend/src/api/map.ts` with proper abort-controller cleanup and ESLint-clean void patterns. Created `frontend/src/types/map.ts` with `TimeRange`, `MapBanItem`, `BansByCountryResponse`.
|
||||
|
||||
### 8.4 Write tests for the map data endpoint
|
||||
### 8.4 Write tests for the map data endpoint ✅ DONE
|
||||
|
||||
Test aggregation correctness: multiple bans from the same country should be summed, unknown countries should be handled gracefully, and empty time ranges should return an empty map object.
|
||||
Added `TestBansByCountry` class (5 tests) to `backend/tests/test_routers/test_dashboard.py`: `test_returns_200_when_authenticated`, `test_returns_401_when_unauthenticated`, `test_response_shape`, `test_accepts_time_range_param`, `test_empty_window_returns_empty_response`. Total backend tests: 290 (all passing). ruff clean, mypy clean (44 files). Frontend: `tsc --noEmit` clean, `eslint` 0 warnings/errors.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
230
frontend/package-lock.json
generated
230
frontend/package-lock.json
generated
@@ -10,9 +10,11 @@
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"react": "^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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
@@ -3084,6 +3086,46 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
|
||||
"integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
|
||||
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
|
||||
"integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-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-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/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3091,6 +3133,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -3133,6 +3181,18 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-simple-maps": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
|
||||
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-geo": "^2",
|
||||
"@types/d3-zoom": "^2",
|
||||
"@types/geojson": "*",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||
@@ -3588,6 +3648,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -3623,6 +3689,102 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
|
||||
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"internmap": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
|
||||
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
|
||||
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
|
||||
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-selection": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
|
||||
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
||||
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
|
||||
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 2"
|
||||
}
|
||||
},
|
||||
"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-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/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4176,6 +4338,12 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
|
||||
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -4413,6 +4581,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -4571,6 +4749,18 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4615,6 +4805,13 @@
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -4657,6 +4854,23 @@
|
||||
"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/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -4850,6 +5064,20 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"react": "^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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
|
||||
19
frontend/src/api/map.ts
Normal file
19
frontend/src/api/map.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* API functions for the world map / bans-by-country endpoint.
|
||||
*/
|
||||
|
||||
import { get } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type { BansByCountryResponse, TimeRange } from "../types/map";
|
||||
|
||||
/**
|
||||
* Fetch ban counts aggregated by country for the given time window.
|
||||
*
|
||||
* @param range - Time-range preset.
|
||||
*/
|
||||
export async function fetchBansByCountry(
|
||||
range: TimeRange = "24h",
|
||||
): Promise<BansByCountryResponse> {
|
||||
const url = `${ENDPOINTS.dashboardBansByCountry}?range=${encodeURIComponent(range)}`;
|
||||
return get<BansByCountryResponse>(url);
|
||||
}
|
||||
190
frontend/src/components/WorldMap.tsx
Normal file
190
frontend/src/components/WorldMap.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* WorldMap — SVG world map showing per-country ban counts.
|
||||
*
|
||||
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from
|
||||
* jsDelivr CDN. For each country that has bans in the selected time window,
|
||||
* the total count is displayed inside the country's borders. Clicking a
|
||||
* country filters the companion table.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { ComposableMap, Geography, useGeographies } from "react-simple-maps";
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
import type { GeoPermissibleObjects } from "d3-geo";
|
||||
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GEO_URL =
|
||||
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
mapWrapper: {
|
||||
width: "100%",
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
overflow: "hidden",
|
||||
},
|
||||
countLabel: {
|
||||
fontSize: "9px",
|
||||
fontWeight: "600",
|
||||
fill: tokens.colorNeutralForeground1,
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colour utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Map a ban count to a fill colour intensity. */
|
||||
function getFill(count: number, maxCount: number): string {
|
||||
if (count === 0 || maxCount === 0) return "#E8E8E8";
|
||||
const intensity = count / maxCount;
|
||||
// Interpolate from light amber to deep red
|
||||
const r = Math.round(220 + (220 - 220) * intensity);
|
||||
const g = Math.round(200 - 180 * intensity);
|
||||
const b = Math.round(160 - 160 * intensity);
|
||||
return `rgb(${String(r)},${String(g)},${String(b)})`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GeoLayerProps {
|
||||
countries: Record<string, number>;
|
||||
maxCount: number;
|
||||
selectedCountry: string | null;
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
}
|
||||
|
||||
function GeoLayer({
|
||||
countries,
|
||||
maxCount,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
}: GeoLayerProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
||||
|
||||
const handleClick = useCallback(
|
||||
(cc: string | null): void => {
|
||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||
},
|
||||
[selectedCountry, onSelectCountry],
|
||||
);
|
||||
|
||||
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;
|
||||
const centroid = path.centroid(geo as unknown as GeoPermissibleObjects);
|
||||
const [cx, cy] = centroid;
|
||||
|
||||
const fill = isSelected
|
||||
? tokens.colorBrandBackground
|
||||
: getFill(count, maxCount);
|
||||
|
||||
return (
|
||||
<g
|
||||
key={geo.rsmKey}
|
||||
style={{ cursor: cc ? "pointer" : "default" }}
|
||||
onClick={(): void => {
|
||||
if (cc) handleClick(cc);
|
||||
}}
|
||||
>
|
||||
<Geography
|
||||
geography={geo}
|
||||
style={{
|
||||
default: {
|
||||
fill,
|
||||
stroke: tokens.colorNeutralBackground1,
|
||||
strokeWidth: 0.5,
|
||||
outline: "none",
|
||||
},
|
||||
hover: {
|
||||
fill: cc ? tokens.colorBrandBackgroundHover : fill,
|
||||
stroke: tokens.colorNeutralBackground1,
|
||||
strokeWidth: 0.5,
|
||||
outline: "none",
|
||||
},
|
||||
pressed: {
|
||||
fill: tokens.colorBrandBackgroundPressed,
|
||||
stroke: tokens.colorNeutralBackground1,
|
||||
strokeWidth: 0.5,
|
||||
outline: "none",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{count > 0 && isFinite(cx) && isFinite(cy) && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorldMap — public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorldMapProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** Currently selected country filter (null means no filter). */
|
||||
selectedCountry: string | null;
|
||||
/** Called when the user clicks a country or deselects. */
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
}
|
||||
|
||||
export function WorldMap({
|
||||
countries,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
}: WorldMapProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const maxCount = Math.max(0, ...Object.values(countries));
|
||||
|
||||
return (
|
||||
<div className={styles.mapWrapper}>
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
||||
width={800}
|
||||
height={400}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
>
|
||||
<GeoLayer
|
||||
countries={countries}
|
||||
maxCount={maxCount}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={onSelectCountry}
|
||||
/>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
frontend/src/data/isoNumericToAlpha2.ts
Normal file
248
frontend/src/data/isoNumericToAlpha2.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Static mapping from ISO 3166-1 numeric country codes (as used in the
|
||||
* world-atlas TopoJSON data) to ISO 3166-1 alpha-2 codes (as returned by
|
||||
* the ip-api.com geo-enrichment service).
|
||||
*
|
||||
* Source: ISO 3166 Maintenance Agency (iso.org)
|
||||
*/
|
||||
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"4": "AF",
|
||||
"8": "AL",
|
||||
"12": "DZ",
|
||||
"16": "AS",
|
||||
"20": "AD",
|
||||
"24": "AO",
|
||||
"28": "AG",
|
||||
"31": "AZ",
|
||||
"32": "AR",
|
||||
"36": "AU",
|
||||
"40": "AT",
|
||||
"44": "BS",
|
||||
"48": "BH",
|
||||
"50": "BD",
|
||||
"51": "AM",
|
||||
"52": "BB",
|
||||
"56": "BE",
|
||||
"64": "BT",
|
||||
"68": "BO",
|
||||
"70": "BA",
|
||||
"72": "BW",
|
||||
"76": "BR",
|
||||
"84": "BZ",
|
||||
"86": "IO",
|
||||
"90": "SB",
|
||||
"96": "BN",
|
||||
"100": "BG",
|
||||
"104": "MM",
|
||||
"108": "BI",
|
||||
"112": "BY",
|
||||
"116": "KH",
|
||||
"120": "CM",
|
||||
"124": "CA",
|
||||
"132": "CV",
|
||||
"136": "KY",
|
||||
"140": "CF",
|
||||
"144": "LK",
|
||||
"148": "TD",
|
||||
"152": "CL",
|
||||
"156": "CN",
|
||||
"162": "CX",
|
||||
"166": "CC",
|
||||
"170": "CO",
|
||||
"174": "KM",
|
||||
"175": "YT",
|
||||
"178": "CG",
|
||||
"180": "CD",
|
||||
"184": "CK",
|
||||
"188": "CR",
|
||||
"191": "HR",
|
||||
"192": "CU",
|
||||
"196": "CY",
|
||||
"203": "CZ",
|
||||
"204": "BJ",
|
||||
"208": "DK",
|
||||
"212": "DM",
|
||||
"214": "DO",
|
||||
"218": "EC",
|
||||
"222": "SV",
|
||||
"226": "GQ",
|
||||
"231": "ET",
|
||||
"232": "ER",
|
||||
"233": "EE",
|
||||
"238": "FK",
|
||||
"242": "FJ",
|
||||
"246": "FI",
|
||||
"248": "AX",
|
||||
"250": "FR",
|
||||
"254": "GF",
|
||||
"258": "PF",
|
||||
"262": "DJ",
|
||||
"266": "GA",
|
||||
"268": "GE",
|
||||
"270": "GM",
|
||||
"275": "PS",
|
||||
"276": "DE",
|
||||
"288": "GH",
|
||||
"292": "GI",
|
||||
"296": "KI",
|
||||
"300": "GR",
|
||||
"304": "GL",
|
||||
"308": "GD",
|
||||
"312": "GP",
|
||||
"316": "GU",
|
||||
"320": "GT",
|
||||
"324": "GN",
|
||||
"328": "GY",
|
||||
"332": "HT",
|
||||
"334": "HM",
|
||||
"336": "VA",
|
||||
"340": "HN",
|
||||
"344": "HK",
|
||||
"348": "HU",
|
||||
"352": "IS",
|
||||
"356": "IN",
|
||||
"360": "ID",
|
||||
"364": "IR",
|
||||
"368": "IQ",
|
||||
"372": "IE",
|
||||
"376": "IL",
|
||||
"380": "IT",
|
||||
"388": "JM",
|
||||
"392": "JP",
|
||||
"398": "KZ",
|
||||
"400": "JO",
|
||||
"404": "KE",
|
||||
"408": "KP",
|
||||
"410": "KR",
|
||||
"414": "KW",
|
||||
"417": "KG",
|
||||
"418": "LA",
|
||||
"422": "LB",
|
||||
"426": "LS",
|
||||
"428": "LV",
|
||||
"430": "LR",
|
||||
"434": "LY",
|
||||
"438": "LI",
|
||||
"440": "LT",
|
||||
"442": "LU",
|
||||
"446": "MO",
|
||||
"450": "MG",
|
||||
"454": "MW",
|
||||
"458": "MY",
|
||||
"462": "MV",
|
||||
"466": "ML",
|
||||
"470": "MT",
|
||||
"474": "MQ",
|
||||
"478": "MR",
|
||||
"480": "MU",
|
||||
"484": "MX",
|
||||
"492": "MC",
|
||||
"496": "MN",
|
||||
"498": "MD",
|
||||
"499": "ME",
|
||||
"500": "MS",
|
||||
"504": "MA",
|
||||
"508": "MZ",
|
||||
"512": "OM",
|
||||
"516": "NA",
|
||||
"520": "NR",
|
||||
"524": "NP",
|
||||
"528": "NL",
|
||||
"531": "CW",
|
||||
"533": "AW",
|
||||
"534": "SX",
|
||||
"535": "BQ",
|
||||
"540": "NC",
|
||||
"548": "VU",
|
||||
"554": "NZ",
|
||||
"558": "NI",
|
||||
"562": "NE",
|
||||
"566": "NG",
|
||||
"570": "NU",
|
||||
"574": "NF",
|
||||
"578": "NO",
|
||||
"580": "MP",
|
||||
"583": "FM",
|
||||
"584": "MH",
|
||||
"585": "PW",
|
||||
"586": "PK",
|
||||
"591": "PA",
|
||||
"598": "PG",
|
||||
"600": "PY",
|
||||
"604": "PE",
|
||||
"608": "PH",
|
||||
"612": "PN",
|
||||
"616": "PL",
|
||||
"620": "PT",
|
||||
"624": "GW",
|
||||
"626": "TL",
|
||||
"630": "PR",
|
||||
"634": "QA",
|
||||
"638": "RE",
|
||||
"642": "RO",
|
||||
"643": "RU",
|
||||
"646": "RW",
|
||||
"652": "BL",
|
||||
"654": "SH",
|
||||
"659": "KN",
|
||||
"660": "AI",
|
||||
"662": "LC",
|
||||
"663": "MF",
|
||||
"666": "PM",
|
||||
"670": "VC",
|
||||
"674": "SM",
|
||||
"678": "ST",
|
||||
"682": "SA",
|
||||
"686": "SN",
|
||||
"688": "RS",
|
||||
"690": "SC",
|
||||
"694": "SL",
|
||||
"703": "SK",
|
||||
"704": "VN",
|
||||
"705": "SI",
|
||||
"706": "SO",
|
||||
"710": "ZA",
|
||||
"716": "ZW",
|
||||
"724": "ES",
|
||||
"728": "SS",
|
||||
"729": "SD",
|
||||
"732": "EH",
|
||||
"736": "SD",
|
||||
"740": "SR",
|
||||
"744": "SJ",
|
||||
"748": "SZ",
|
||||
"752": "SE",
|
||||
"756": "CH",
|
||||
"760": "SY",
|
||||
"762": "TJ",
|
||||
"764": "TH",
|
||||
"768": "TG",
|
||||
"772": "TK",
|
||||
"776": "TO",
|
||||
"780": "TT",
|
||||
"784": "AE",
|
||||
"788": "TN",
|
||||
"792": "TR",
|
||||
"795": "TM",
|
||||
"796": "TC",
|
||||
"798": "TV",
|
||||
"800": "UG",
|
||||
"804": "UA",
|
||||
"807": "MK",
|
||||
"818": "EG",
|
||||
"826": "GB",
|
||||
"831": "GG",
|
||||
"832": "JE",
|
||||
"833": "IM",
|
||||
"834": "TZ",
|
||||
"840": "US",
|
||||
"850": "VI",
|
||||
"854": "BF",
|
||||
"858": "UY",
|
||||
"860": "UZ",
|
||||
"862": "VE",
|
||||
"876": "WF",
|
||||
"882": "WS",
|
||||
"887": "YE",
|
||||
"894": "ZM",
|
||||
};
|
||||
76
frontend/src/hooks/useMapData.ts
Normal file
76
frontend/src/hooks/useMapData.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `useMapData` hook — fetches and manages ban-by-country data.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchBansByCountry } from "../api/map";
|
||||
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseMapDataResult {
|
||||
/** Per-country ban counts (ISO alpha-2 → count). */
|
||||
countries: Record<string, number>;
|
||||
/** ISO alpha-2 → country name mapping. */
|
||||
countryNames: Record<string, string>;
|
||||
/** All ban records in the selected window. */
|
||||
bans: MapBanItem[];
|
||||
/** Total ban count. */
|
||||
total: number;
|
||||
/** True while a fetch is in flight. */
|
||||
loading: boolean;
|
||||
/** Error message or null. */
|
||||
error: string | null;
|
||||
/** Trigger a manual re-fetch. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMapData(range: TimeRange = "24h"): UseMapDataResult {
|
||||
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchBansByCountry(range)
|
||||
.then((resp) => {
|
||||
setData(resp);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [range]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return {
|
||||
countries: data?.countries ?? {},
|
||||
countryNames: data?.country_names ?? {},
|
||||
bans: data?.bans ?? [],
|
||||
total: data?.total ?? 0,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,259 @@
|
||||
/**
|
||||
* World Map placeholder page — full implementation in Stage 5.
|
||||
* MapPage — geographical overview of fail2ban bans.
|
||||
*
|
||||
* Shows a clickable SVG world map coloured by ban density, a time-range
|
||||
* selector, and a companion table filtered by the selected country (or all
|
||||
* bans when no country is selected).
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellLayout,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
Toolbar,
|
||||
ToolbarButton,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
||||
import { WorldMap } from "../components/WorldMap";
|
||||
import { useMapData } from "../hooks/useMapData";
|
||||
import type { TimeRange } from "../types/map";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
paddingLeft: tokens.spacingHorizontalXXL,
|
||||
paddingRight: tokens.spacingHorizontalXXL,
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
},
|
||||
filterBar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||
background: tokens.colorNeutralBackground3,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
},
|
||||
tableWrapper: {
|
||||
overflow: "auto",
|
||||
maxHeight: "420px",
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time-range options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
||||
{ label: "Last 24 hours", value: "24h" },
|
||||
{ label: "Last 7 days", value: "7d" },
|
||||
{ label: "Last 30 days", value: "30d" },
|
||||
{ label: "Last 365 days", value: "365d" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MapPage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MapPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [range, setRange] = useState<TimeRange>("24h");
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
||||
|
||||
const { countries, countryNames, bans, total, loading, error, refresh } =
|
||||
useMapData(range);
|
||||
|
||||
/** Bans visible in the companion table (filtered by selected country). */
|
||||
const visibleBans = useMemo(() => {
|
||||
if (!selectedCountry) return bans;
|
||||
return bans.filter((b) => b.country_code === selectedCountry);
|
||||
}, [bans, selectedCountry]);
|
||||
|
||||
const selectedCountryName = selectedCountry
|
||||
? (countryNames[selectedCountry] ?? selectedCountry)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
World Map
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Geographical ban overview will be implemented in Stage 5.
|
||||
</Text>
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Header row */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<div className={styles.header}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
World Map
|
||||
</Text>
|
||||
|
||||
<Toolbar size="small">
|
||||
<Select
|
||||
aria-label="Time range"
|
||||
value={range}
|
||||
onChange={(_ev, data): void => {
|
||||
setRange(data.value as TimeRange);
|
||||
setSelectedCountry(null);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{TIME_RANGE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<ArrowCounterclockwiseRegular />}
|
||||
onClick={(): void => {
|
||||
refresh();
|
||||
}}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
/>
|
||||
</Toolbar>
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Error / loading states */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}>
|
||||
<Spinner label="Loading map data…" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* World map */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
<WorldMap
|
||||
countries={countries}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={setSelectedCountry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Active country filter info bar */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{selectedCountry && (
|
||||
<div className={styles.filterBar}>
|
||||
<Text size={300}>
|
||||
Showing <strong>{String(visibleBans.length)}</strong> bans from{" "}
|
||||
<strong>{selectedCountryName ?? selectedCountry}</strong>
|
||||
{" "}({String(countries[selectedCountry] ?? 0)} total in window)
|
||||
</Text>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
icon={<DismissRegular />}
|
||||
onClick={(): void => {
|
||||
setSelectedCountry(null);
|
||||
}}
|
||||
>
|
||||
Clear filter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Summary line */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Companion bans table */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<Table size="small" aria-label="Bans list">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>IP Address</TableHeaderCell>
|
||||
<TableHeaderCell>Jail</TableHeaderCell>
|
||||
<TableHeaderCell>Banned At</TableHeaderCell>
|
||||
<TableHeaderCell>Country</TableHeaderCell>
|
||||
<TableHeaderCell>Times Banned</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleBans.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<TableCellLayout>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No bans found.
|
||||
</Text>
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
visibleBans.map((ban) => (
|
||||
<TableRow key={`${ban.ip}-${ban.banned_at}`}>
|
||||
<TableCell>
|
||||
<TableCellLayout>{ban.ip}</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout>{ban.jail}</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout>
|
||||
{new Date(ban.banned_at).toLocaleString()}
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout>
|
||||
{ban.country_name ?? ban.country_code ?? "—"}
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout>{String(ban.ban_count)}</TableCellLayout>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
31
frontend/src/types/map.ts
Normal file
31
frontend/src/types/map.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* TypeScript types for the world-map / bans-by-country API.
|
||||
*/
|
||||
|
||||
/** Time-range preset for filtering queries. */
|
||||
export type TimeRange = "24h" | "7d" | "30d" | "365d";
|
||||
|
||||
/** A single enriched ban item as returned by the by-country endpoint. */
|
||||
export interface MapBanItem {
|
||||
ip: string;
|
||||
jail: string;
|
||||
banned_at: string;
|
||||
service: string | null;
|
||||
country_code: string | null;
|
||||
country_name: string | null;
|
||||
asn: string | null;
|
||||
org: string | null;
|
||||
ban_count: number;
|
||||
}
|
||||
|
||||
/** Response from GET /api/dashboard/bans/by-country */
|
||||
export interface BansByCountryResponse {
|
||||
/** ISO alpha-2 country code → ban count */
|
||||
countries: Record<string, number>;
|
||||
/** ISO alpha-2 country code → human-readable country name */
|
||||
country_names: Record<string, string>;
|
||||
/** All individual ban records in the window (up to server limit) */
|
||||
bans: MapBanItem[];
|
||||
/** Total ban count in the window */
|
||||
total: number;
|
||||
}
|
||||
Reference in New Issue
Block a user