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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user