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:
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