diff --git a/Docs/Tasks.md b/Docs/Tasks.md index ee74ec5..3296809 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -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. --- diff --git a/backend/app/models/ban.py b/backend/app/models/ban.py index ec30cbd..aee9fd0 100644 --- a/backend/app/models/ban.py +++ b/backend/app/models/ban.py @@ -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.") diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index fe6099e..749cd82 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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, + ) + diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index 98091c8..9144517 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_service.py @@ -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, + ) diff --git a/backend/tests/test_routers/test_dashboard.py b/backend/tests/test_routers/test_dashboard.py index cdecd73..454bbf5 100644 --- a/backend/tests/test_routers/test_dashboard.py +++ b/backend/tests/test_routers/test_dashboard.py @@ -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"] == [] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d038fa8..3f214fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index f3560e2..aebd8ea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/map.ts b/frontend/src/api/map.ts new file mode 100644 index 0000000..107173f --- /dev/null +++ b/frontend/src/api/map.ts @@ -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 { + const url = `${ENDPOINTS.dashboardBansByCountry}?range=${encodeURIComponent(range)}`; + return get(url); +} diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx new file mode 100644 index 0000000..133957f --- /dev/null +++ b/frontend/src/components/WorldMap.tsx @@ -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; + 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 ( + { + if (cc) handleClick(cc); + }} + > + + {count > 0 && isFinite(cx) && isFinite(cy) && ( + + {count} + + )} + + ); + }, + )} + + ); +} + +// --------------------------------------------------------------------------- +// WorldMap — public component +// --------------------------------------------------------------------------- + +export interface WorldMapProps { + /** ISO alpha-2 country code → ban count. */ + countries: Record; + /** 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 ( +
+ + + +
+ ); +} diff --git a/frontend/src/data/isoNumericToAlpha2.ts b/frontend/src/data/isoNumericToAlpha2.ts new file mode 100644 index 0000000..cca5dc2 --- /dev/null +++ b/frontend/src/data/isoNumericToAlpha2.ts @@ -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 = { + "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", +}; diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts new file mode 100644 index 0000000..4c595b4 --- /dev/null +++ b/frontend/src/hooks/useMapData.ts @@ -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; + /** ISO alpha-2 → country name mapping. */ + countryNames: Record; + /** 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const abortRef = useRef(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, + }; +} diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index 09fa8be..bd13323 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -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("24h"); + const [selectedCountry, setSelectedCountry] = useState(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 (
- - World Map - - - Geographical ban overview will be implemented in Stage 5. - + {/* ---------------------------------------------------------------- */} + {/* Header row */} + {/* ---------------------------------------------------------------- */} +
+ + World Map + + + + + + } + onClick={(): void => { + refresh(); + }} + disabled={loading} + title="Refresh" + /> + +
+ + {/* ---------------------------------------------------------------- */} + {/* Error / loading states */} + {/* ---------------------------------------------------------------- */} + {error && ( + + {error} + + )} + + {loading && !error && ( +
+ +
+ )} + + {/* ---------------------------------------------------------------- */} + {/* World map */} + {/* ---------------------------------------------------------------- */} + {!loading && !error && ( + + )} + + {/* ---------------------------------------------------------------- */} + {/* Active country filter info bar */} + {/* ---------------------------------------------------------------- */} + {selectedCountry && ( +
+ + Showing {String(visibleBans.length)} bans from{" "} + {selectedCountryName ?? selectedCountry} + {" "}({String(countries[selectedCountry] ?? 0)} total in window) + + +
+ )} + + {/* ---------------------------------------------------------------- */} + {/* Summary line */} + {/* ---------------------------------------------------------------- */} + {!loading && !error && ( + + {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 + + )} + + {/* ---------------------------------------------------------------- */} + {/* Companion bans table */} + {/* ---------------------------------------------------------------- */} + {!loading && !error && ( +
+ + + + IP Address + Jail + Banned At + Country + Times Banned + + + + {visibleBans.length === 0 ? ( + + + + + No bans found. + + + + + ) : ( + visibleBans.map((ban) => ( + + + {ban.ip} + + + {ban.jail} + + + + {new Date(ban.banned_at).toLocaleString()} + + + + + {ban.country_name ?? ban.country_code ?? "—"} + + + + {String(ban.ban_count)} + + + )) + )} + +
+
+ )}
); } diff --git a/frontend/src/types/map.ts b/frontend/src/types/map.ts new file mode 100644 index 0000000..3b4ce84 --- /dev/null +++ b/frontend/src/types/map.ts @@ -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; + /** ISO alpha-2 country code → human-readable country name */ + country_names: Record; + /** All individual ban records in the window (up to server limit) */ + bans: MapBanItem[]; + /** Total ban count in the window */ + total: number; +}