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

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

19
frontend/src/api/map.ts Normal file
View 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);
}

View 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>
);
}

View 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",
};

View 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,
};
}

View File

@@ -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
View 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;
}