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

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

View File

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