Add origin field and filter for ban sources (Tasks 1 & 2)

- Task 1: Mark imported blocklist IP addresses
  - Add BanOrigin type and _derive_origin() to ban.py model
  - Populate origin field in ban_service list_bans() and bans_by_country()
  - BanTable and MapPage companion table show origin badge column
  - Tests: origin derivation in test_ban_service.py and test_dashboard.py

- Task 2: Add origin filter to dashboard and world map
  - ban_service: _origin_sql_filter() helper; origin param on list_bans()
    and bans_by_country()
  - dashboard router: optional origin query param forwarded to service
  - Frontend: BanOriginFilter type + BAN_ORIGIN_FILTER_LABELS in ban.ts
  - fetchBans / fetchBansByCountry forward origin to API
  - useBans / useMapData accept and pass origin; page resets on change
  - BanTable accepts origin prop; DashboardPage adds segmented filter
  - MapPage adds origin Select next to time-range picker
  - Tests: origin filter assertions in test_ban_service and test_dashboard
This commit is contained in:
2026-03-07 20:03:43 +01:00
parent 706d2e1df8
commit 53d664de4f
28 changed files with 1637 additions and 103 deletions

View File

@@ -7,11 +7,12 @@
* 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 { useCallback, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
import { Button, makeStyles, tokens } from "@fluentui/react-components";
import type { GeoPermissibleObjects } from "d3-geo";
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
import { getBanCountColor } from "../utils/mapColors";
// ---------------------------------------------------------------------------
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
@@ -27,6 +28,7 @@ const GEO_URL =
const useStyles = makeStyles({
mapWrapper: {
width: "100%",
position: "relative",
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
@@ -39,39 +41,37 @@ const useStyles = makeStyles({
pointerEvents: "none",
userSelect: "none",
},
zoomControls: {
position: "absolute",
top: tokens.spacingVerticalM,
right: tokens.spacingHorizontalM,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
zIndex: 10,
},
});
// ---------------------------------------------------------------------------
// 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;
thresholdLow: number;
thresholdMedium: number;
thresholdHigh: number;
}
function GeoLayer({
countries,
maxCount,
selectedCountry,
onSelectCountry,
thresholdLow,
thresholdMedium,
thresholdHigh,
}: GeoLayerProps): React.JSX.Element {
const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL });
@@ -83,11 +83,12 @@ function GeoLayer({
[selectedCountry, onSelectCountry],
);
// react-simple-maps types declare `path` as always defined, but it is
// undefined during early renders before the MapProvider context initialises.
// Cast through unknown to reflect the true runtime type and guard safely.
if (geographies.length === 0) return <></>;
// react-simple-maps types declare path as always defined, but it can be null
// during initial render before MapProvider context initializes. Cast to reflect
// the true runtime type and allow safe null checking.
const safePath = path as unknown as typeof path | null;
if (safePath == null) return <></>;
return (
<>
@@ -97,12 +98,22 @@ function GeoLayer({
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);
// Compute the fill color based on ban count
const fillColor = getBanCountColor(
count,
thresholdLow,
thresholdMedium,
thresholdHigh,
);
// Only calculate centroid if path is available
let cx: number | undefined;
let cy: number | undefined;
if (safePath != null) {
const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects);
[cx, cy] = centroid;
}
return (
<g
@@ -130,26 +141,30 @@ function GeoLayer({
geography={geo}
style={{
default: {
fill,
stroke: tokens.colorNeutralBackground1,
strokeWidth: 0.5,
fill: isSelected ? tokens.colorBrandBackground : fillColor,
stroke: tokens.colorNeutralStroke2,
strokeWidth: 0.75,
outline: "none",
},
hover: {
fill: cc ? tokens.colorBrandBackgroundHover : fill,
stroke: tokens.colorNeutralBackground1,
strokeWidth: 0.5,
fill: isSelected
? tokens.colorBrandBackgroundHover
: cc && count > 0
? tokens.colorNeutralBackground3
: fillColor,
stroke: tokens.colorNeutralStroke1,
strokeWidth: 1,
outline: "none",
},
pressed: {
fill: tokens.colorBrandBackgroundPressed,
stroke: tokens.colorNeutralBackground1,
strokeWidth: 0.5,
fill: cc ? tokens.colorBrandBackgroundPressed : fillColor,
stroke: tokens.colorBrandStroke1,
strokeWidth: 1,
outline: "none",
},
}}
/>
{count > 0 && isFinite(cx) && isFinite(cy) && (
{count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && (
<text
x={cx}
y={cy}
@@ -179,15 +194,38 @@ export interface WorldMapProps {
selectedCountry: string | null;
/** Called when the user clicks a country or deselects. */
onSelectCountry: (cc: string | null) => void;
/** Ban count threshold for green coloring (default: 20). */
thresholdLow?: number;
/** Ban count threshold for yellow coloring (default: 50). */
thresholdMedium?: number;
/** Ban count threshold for red coloring (default: 100). */
thresholdHigh?: number;
}
export function WorldMap({
countries,
selectedCountry,
onSelectCountry,
thresholdLow = 20,
thresholdMedium = 50,
thresholdHigh = 100,
}: WorldMapProps): React.JSX.Element {
const styles = useStyles();
const maxCount = Math.max(0, ...Object.values(countries));
const [zoom, setZoom] = useState<number>(1);
const [center, setCenter] = useState<[number, number]>([0, 0]);
const handleZoomIn = (): void => {
setZoom((z) => Math.min(z + 0.5, 8));
};
const handleZoomOut = (): void => {
setZoom((z) => Math.max(z - 0.5, 1));
};
const handleResetView = (): void => {
setZoom(1);
setCenter([0, 0]);
};
return (
<div
@@ -195,6 +233,40 @@ export function WorldMap({
role="img"
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
>
{/* Zoom controls */}
<div className={styles.zoomControls}>
<Button
appearance="secondary"
size="small"
onClick={handleZoomIn}
disabled={zoom >= 8}
title="Zoom in"
aria-label="Zoom in"
>
+
</Button>
<Button
appearance="secondary"
size="small"
onClick={handleZoomOut}
disabled={zoom <= 1}
title="Zoom out"
aria-label="Zoom out"
>
</Button>
<Button
appearance="secondary"
size="small"
onClick={handleResetView}
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
title="Reset view"
aria-label="Reset view"
>
</Button>
</div>
<ComposableMap
projection="geoMercator"
projectionConfig={{ scale: 130, center: [10, 20] }}
@@ -202,12 +274,25 @@ export function WorldMap({
height={400}
style={{ width: "100%", height: "auto" }}
>
<GeoLayer
countries={countries}
maxCount={maxCount}
selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry}
/>
<ZoomableGroup
zoom={zoom}
center={center}
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
setZoom(newZoom);
setCenter(coordinates);
}}
minZoom={1}
maxZoom={8}
>
<GeoLayer
countries={countries}
selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry}
thresholdLow={thresholdLow}
thresholdMedium={thresholdMedium}
thresholdHigh={thresholdHigh}
/>
</ZoomableGroup>
</ComposableMap>
</div>
);