From bfe0daf7541fa59411bce434b1530878755517e2 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 1 Apr 2026 14:53:38 +0200 Subject: [PATCH 01/11] Fix WorldMap hover highlight by memoizing style objects and handlers Memoize per-Geography style objects with useMemo so React.memo can skip re-renders when only the tooltip position changes. Stabilize mouse event handlers with useCallback using data-* attributes instead of per-Geography closures. This eliminates the state-update race condition that caused hover fill colors to flash back to defaults. --- Docs/Tasks.md | 100 ++++++++++++++------ frontend/src/components/WorldMap.tsx | 133 +++++++++++++++------------ 2 files changed, 147 insertions(+), 86 deletions(-) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 5e2cf75..90e7088 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -8,37 +8,79 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. ## Open Issues -### 1. History Screen — Move Jail & IP Address Filters Into the Time Range Bar +### ~~WorldMap hover highlight: fill color sometimes does not change~~ ✅ Done -**Goal:** Unify all History-page filters into a single bordered bar so that Jail and IP Address sit inside the same card/border as Time Range and Filter, separated by vertical dividers. +**Status:** Completed. -**Current state:** -- `DashboardFilterBar` (`frontend/src/components/DashboardFilterBar.tsx`) renders a single bordered card (`cardStyles.card`) that contains two groups — **Time Range** (toggle buttons) and **Filter** (origin toggle buttons) — separated by a vertical ``. -- In `HistoryPage` (`frontend/src/pages/HistoryPage.tsx`, lines 476–510) the Jail `` and IP Address `` are rendered **outside** that bar as separate cards, each wrapped in their own `cardStyles.card` div, laid out horizontally via `styles.filterRow` (flexbox row with gap). +**Summary:** Memoized Geography style objects via `useMemo` keyed by `geographies`, `countries`, `selectedCountry`, and threshold props. Stabilized `onMouseEnter`, `onMouseMove`, and `onMouseLeave` handlers with `useCallback` using `data-*` attributes to avoid per-Geography closures. This ensures `React.memo(Geography)` correctly skips re-renders when only tooltip position changes, eliminating the state-update race that caused hover fills to flash back to defaults. -**Desired state:** -- The Jail and IP Address inputs must move **inside** the `DashboardFilterBar` card border (or the equivalent combined container) so the entire filter strip looks like one cohesive section. -- Each new group (Jail, IP Address) is separated from its neighbor by a vertical divider (`|`), using the same `` + `styles.divider` pattern already used between Time Range and Filter. -- Inside each group the label text ("Jail", "IP Address") must appear **to the left** of its input field (i.e. `flexDirection: "row"` with `alignItems: "center"`, not above it). This matches the existing group style where the title text sits to the left of the toolbar buttons. -- The visual order inside the bar is: **Time Range** | **Filter** | **Jail** | **IP Address**. +**Symptom:** On mouse-over, a country's stroke correctly becomes bold (`strokeWidth: 1`), but the background fill colour sometimes stays at the default value instead of switching to the hover colour. + +**Root cause — `React.memo` is defeated by new style objects on every render:** + +`Geography.js` (`react-simple-maps-main/src/components/Geography.js`) is wrapped in `memo()`. It keeps an internal `isFocused` state that toggles between `style.default` and `style.hover`. This works correctly in isolation. + +However, in `WorldMap.tsx` (`frontend/src/components/WorldMap.tsx`): + +1. **`onMouseMove`** calls `setTooltip(...)` on every pixel of mouse movement, triggering a GeoLayer re-render. +2. Each GeoLayer render creates a **new `style` object literal** for every `` (lines ~199–222). Because `memo()` does a shallow reference comparison, it sees a "new" `style` prop every time and **re-renders all ~200 Geography components** on every mouse-move event. +3. During these rapid cascading re-renders, React's batched state updates can cause `Geography`'s internal `setFocus(true)` (fired in `handleMouseEnter`) and the parent's `setTooltip` to race. When the parent re-render is processed before `isFocused` is committed, Geography momentarily renders with `isFocused = false` and picks `style.default` — the fill stays at the default colour while the stroke (which changes less dramatically) appears correct. + +A secondary contributing factor: for countries with `count === 0` that are not selected, `style.hover.fill` equals `style.default.fill` (both resolve to `fillColor`), so there is intentionally no visible fill change on hover for those countries. This may overlap with the perceived bug if the user expects all countries to highlight. + +**Fix — memoize the style objects so `memo()` can skip re-renders:** + +In `WorldMap.tsx`, compute each Geography's style object with `useMemo` (or move it outside the `.map()` callback with stable references). Concretely: + +1. **Create a `useMemo`-based style map** keyed by `geo.rsmKey` that only recomputes when `countries`, `selectedCountry`, or the threshold props change — not on every tooltip update. + + ```tsx + const styleMap = useMemo(() => { + const map: Record = {}; + for (const geo of geographies as { rsmKey: string; id: string | number }[]) { + const numericId = String(geo.id); + const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; + const count = cc !== null ? (countries[cc] ?? 0) : 0; + const isSelected = cc !== null && selectedCountry === cc; + const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh); + map[geo.rsmKey] = { + default: { + fill: isSelected ? tokens.colorBrandBackground : fillColor, + stroke: tokens.colorNeutralStroke2, + strokeWidth: 0.75, + outline: "none", + }, + hover: { + fill: isSelected + ? tokens.colorBrandBackgroundHover + : cc && count > 0 + ? tokens.colorNeutralBackground3 + : fillColor, + stroke: tokens.colorNeutralStroke1, + strokeWidth: 1, + outline: "none", + }, + pressed: { + fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, + stroke: tokens.colorBrandStroke1, + strokeWidth: 1, + outline: "none", + }, + }; + } + return map; + }, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]); + ``` + +2. **Pass the memoized style** in the `.map()` callback: + ```tsx + + ``` + +3. **Wrap event handlers in `useCallback`** (`onMouseEnter`, `onMouseMove`, `onMouseLeave`) or move them outside the `.map()` so they are stable references and do not defeat `memo()`. Consider passing `cc`, `count`, and `countryNames` as data attributes and reading them from `e.currentTarget` inside a single shared handler. + +With stable `style` references, `memo(Geography)` will correctly skip re-renders for countries whose data has not changed, eliminating the state-update race condition and the unnecessary rendering of ~200 SVG paths on every mouse-move event. **Files to change:** - -1. **`frontend/src/components/DashboardFilterBar.tsx`** - - Accept two new optional props (e.g. `jailSlot?: React.ReactNode` and `ipSlot?: React.ReactNode`, or pass the value+onChange pairs directly). Keep the component reusable — the Dashboard page uses the same component but does not need the Jail/IP inputs, so these slots must be optional. - - After the existing Filter group, conditionally render a `
` + `` followed by a new group for Jail, and repeat for IP Address. - - Each new group should follow the existing `styles.group` layout: a row with the label `` on the left and the `` on the right, separated by `gap: tokens.spacingHorizontalM`. - -2. **`frontend/src/pages/HistoryPage.tsx`** - - Remove the two standalone Jail and IP Address `
` cards (currently wrapped in `styles.filterLabel` + `cardStyles.card`). - - Instead, pass the Jail and IP Address controls into `` via the new props/slots. - - The `styles.filterLabel` style can be removed if no longer used elsewhere. - -**Acceptance criteria:** -- All four filter groups (Time Range, Filter, Jail, IP Address) render inside a single bordered bar. -- Each group is separated by a vertical divider identical to the existing one between Time Range and Filter. -- The labels "Jail" and "IP Address" sit to the **left** of their respective input fields (horizontal layout), not above them. -- The Dashboard page's usage of `DashboardFilterBar` is unaffected (no Jail/IP inputs shown there). -- Existing filter functionality (debounced input, query params, pagination reset) remains unchanged. - -Status: completed +- `frontend/src/components/WorldMap.tsx` — memoize style objects and stabilize event handlers. +- No changes needed in `react-simple-maps-main/src/components/Geography.js`. diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx index 21541f3..d9b4871 100644 --- a/frontend/src/components/WorldMap.tsx +++ b/frontend/src/components/WorldMap.tsx @@ -8,10 +8,11 @@ */ import { createPortal } from "react-dom"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; import { Button, makeStyles, tokens } from "@fluentui/react-components"; import { useCardStyles } from "../theme/commonStyles"; +import type { CSSProperties } from "react"; import type { GeoPermissibleObjects } from "d3-geo"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; import { getBanCountColor } from "../utils/mapColors"; @@ -117,6 +118,73 @@ function GeoLayer({ [selectedCountry, onSelectCountry], ); + // Stable event handlers — shared across all Geography components so + // React.memo is not defeated by new function references each render. + const handleMouseEnter = useCallback( + (e: React.MouseEvent): void => { + const cc = e.currentTarget.dataset.cc; + if (!cc) return; + const count = Number(e.currentTarget.dataset.count); + const name = e.currentTarget.dataset.name ?? cc; + setTooltip({ cc, count, name, x: e.clientX, y: e.clientY }); + }, + [], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent): void => { + setTooltip((current) => + current + ? { ...current, x: e.clientX, y: e.clientY } + : current, + ); + }, + [], + ); + + const handleMouseLeave = useCallback((): void => { + setTooltip(null); + }, []); + + // Memoize style objects so Geography's React.memo can skip re-renders + // when only the tooltip position changes. + type GeoStyle = { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties }; + const styleMap = useMemo(() => { + const map: Record = {}; + for (const geo of geographies as { rsmKey: string; id: string | number }[]) { + 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 fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh); + map[geo.rsmKey] = { + default: { + fill: isSelected ? tokens.colorBrandBackground : fillColor, + stroke: tokens.colorNeutralStroke2, + strokeWidth: 0.75, + outline: "none", + }, + hover: { + fill: isSelected + ? tokens.colorBrandBackgroundHover + : cc && count > 0 + ? tokens.colorNeutralBackground3 + : fillColor, + stroke: tokens.colorNeutralStroke1, + strokeWidth: 1, + outline: "none", + }, + pressed: { + fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, + stroke: tokens.colorBrandStroke1, + strokeWidth: 1, + outline: "none", + }, + }; + } + return map; + }, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]); + if (geographies.length === 0) return <>; // react-simple-maps types declare path as always defined, but it can be null @@ -133,14 +201,6 @@ function GeoLayer({ const count: number = cc !== null ? (countries[cc] ?? 0) : 0; const isSelected = cc !== null && selectedCountry === cc; - // 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; @@ -172,54 +232,13 @@ function GeoLayer({ handleClick(cc); } }} - onMouseEnter={(e): void => { - if (!cc) return; - setTooltip({ - cc, - count, - name: countryNames?.[cc] ?? cc, - x: e.clientX, - y: e.clientY, - }); - }} - onMouseMove={(e): void => { - setTooltip((current) => - current - ? { - ...current, - x: e.clientX, - y: e.clientY, - } - : current, - ); - }} - onMouseLeave={(): void => { - setTooltip(null); - }} - style={{ - default: { - fill: isSelected ? tokens.colorBrandBackground : fillColor, - stroke: tokens.colorNeutralStroke2, - strokeWidth: 0.75, - outline: "none", - }, - hover: { - fill: isSelected - ? tokens.colorBrandBackgroundHover - : cc && count > 0 - ? tokens.colorNeutralBackground3 - : fillColor, - stroke: tokens.colorNeutralStroke1, - strokeWidth: 1, - outline: "none", - }, - pressed: { - fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, - stroke: tokens.colorBrandStroke1, - strokeWidth: 1, - outline: "none", - }, - }} + onMouseEnter={handleMouseEnter} + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} + data-cc={cc ?? undefined} + data-count={cc ? String(count) : undefined} + data-name={cc ? (countryNames?.[cc] ?? cc) : undefined} + style={styleMap[geo.rsmKey]} /> {count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && ( Date: Sun, 5 Apr 2026 18:50:44 +0200 Subject: [PATCH 02/11] Migrate WorldMap to d3-geo, fix TopoJSON country ID mappings and update tests --- Docs/Tasks.md | 169 +++-- frontend/package-lock.json | 190 ++---- frontend/package.json | 7 +- frontend/src/components/WorldMap.tsx | 577 ++++++++++-------- .../components/__tests__/WorldMap.test.tsx | 40 +- frontend/src/data/isoNumericToAlpha2.ts | 4 + frontend/tsconfig.json | 2 + 7 files changed, 505 insertions(+), 484 deletions(-) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 90e7088..d43185d 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -8,79 +8,128 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. ## Open Issues -### ~~WorldMap hover highlight: fill color sometimes does not change~~ ✅ Done +### Replace `react-simple-maps` with `d3-geo` in WorldMap -**Status:** Completed. +The current `WorldMap` component (`frontend/src/components/WorldMap.tsx`) uses the `react-simple-maps` library (`ComposableMap`, `ZoomableGroup`, `Geography`, `useGeographies`). This library wraps d3-geo but adds a heavy abstraction layer and fetches the TopoJSON geography file from a remote CDN at runtime. Replace it with direct d3-geo rendering, following the pattern demonstrated in the reference project at `/media/lukas/Volume/repo/worldmaptest/`. -**Summary:** Memoized Geography style objects via `useMemo` keyed by `geographies`, `countries`, `selectedCountry`, and threshold props. Stabilized `onMouseEnter`, `onMouseMove`, and `onMouseLeave` handlers with `useCallback` using `data-*` attributes to avoid per-Geography closures. This ensures `React.memo(Geography)` correctly skips re-renders when only tooltip position changes, eliminating the state-update race that caused hover fills to flash back to defaults. +Reference: `Docs/Features.md` §4 (World Map View) for the full feature specification. -**Symptom:** On mouse-over, a country's stroke correctly becomes bold (`strokeWidth: 1`), but the background fill colour sometimes stays at the default value instead of switching to the hover colour. +**All existing features must be preserved.** The component's public API (`WorldMapProps`) and behaviour must remain identical so that `MapPage.tsx`, `HistoryPage.tsx`, and the existing unit test continue to work after the migration. -**Root cause — `React.memo` is defeated by new style objects on every render:** +--- -`Geography.js` (`react-simple-maps-main/src/components/Geography.js`) is wrapped in `memo()`. It keeps an internal `isFocused` state that toggles between `style.default` and `style.hover`. This works correctly in isolation. +#### Task 1 — Swap npm dependencies [DONE] -However, in `WorldMap.tsx` (`frontend/src/components/WorldMap.tsx`): +Remove `react-simple-maps` and `@types/react-simple-maps` from `frontend/package.json`. Add the following packages that the new implementation requires: -1. **`onMouseMove`** calls `setTooltip(...)` on every pixel of mouse movement, triggering a GeoLayer re-render. -2. Each GeoLayer render creates a **new `style` object literal** for every `` (lines ~199–222). Because `memo()` does a shallow reference comparison, it sees a "new" `style` prop every time and **re-renders all ~200 Geography components** on every mouse-move event. -3. During these rapid cascading re-renders, React's batched state updates can cause `Geography`'s internal `setFocus(true)` (fired in `handleMouseEnter`) and the parent's `setTooltip` to race. When the parent re-render is processed before `isFocused` is committed, Geography momentarily renders with `isFocused = false` and picks `style.default` — the fill stays at the default colour while the stroke (which changes less dramatically) appears correct. +- `d3-geo` — geographic projection and SVG path generation. +- `@types/d3-geo` — TypeScript definitions for d3-geo. +- `topojson-client` — converts TopoJSON to GeoJSON `FeatureCollection`. +- `@types/topojson-client` — TypeScript definitions for topojson-client. +- `world-atlas` — provides the `countries-110m.json` TopoJSON file as a local npm asset (no more CDN fetch at runtime). -A secondary contributing factor: for countries with `count === 0` that are not selected, `style.hover.fill` equals `style.default.fill` (both resolve to `fillColor`), so there is intentionally no visible fill change on hover for those countries. This may overlap with the perceived bug if the user expects all countries to highlight. +Run `npm install` and verify the lock file updates cleanly. -**Fix — memoize the style objects so `memo()` can skip re-renders:** +--- -In `WorldMap.tsx`, compute each Geography's style object with `useMemo` (or move it outside the `.map()` callback with stable references). Concretely: +#### Task 2 — Rewrite `WorldMap.tsx` to use d3-geo directly [DONE] -1. **Create a `useMemo`-based style map** keyed by `geo.rsmKey` that only recomputes when `countries`, `selectedCountry`, or the threshold props change — not on every tooltip update. +Rewrite the component so that it renders a plain `` with `` elements generated by d3-geo instead of the react-simple-maps wrappers. The implementation should follow this approach (as seen in the reference project): - ```tsx - const styleMap = useMemo(() => { - const map: Record = {}; - for (const geo of geographies as { rsmKey: string; id: string | number }[]) { - const numericId = String(geo.id); - const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; - const count = cc !== null ? (countries[cc] ?? 0) : 0; - const isSelected = cc !== null && selectedCountry === cc; - const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh); - map[geo.rsmKey] = { - default: { - fill: isSelected ? tokens.colorBrandBackground : fillColor, - stroke: tokens.colorNeutralStroke2, - strokeWidth: 0.75, - outline: "none", - }, - hover: { - fill: isSelected - ? tokens.colorBrandBackgroundHover - : cc && count > 0 - ? tokens.colorNeutralBackground3 - : fillColor, - stroke: tokens.colorNeutralStroke1, - strokeWidth: 1, - outline: "none", - }, - pressed: { - fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, - stroke: tokens.colorBrandStroke1, - strokeWidth: 1, - outline: "none", - }, - }; - } - return map; - }, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]); - ``` +1. **Import the TopoJSON locally** — `import worldData from "world-atlas/countries-110m.json"` instead of fetching from a CDN URL. Use `topojson-client`'s `feature()` to extract the GeoJSON `FeatureCollection` once (memoised). -2. **Pass the memoized style** in the `.map()` callback: - ```tsx - - ``` +2. **Create a projection** — Use `geoMercator()` from d3-geo (matching the current Mercator projection) with `.fitSize([width, height], featureCollection)` to auto-scale. Memoise the projection so it is only recomputed when the geometry changes. -3. **Wrap event handlers in `useCallback`** (`onMouseEnter`, `onMouseMove`, `onMouseLeave`) or move them outside the `.map()` so they are stable references and do not defeat `memo()`. Consider passing `cc`, `count`, and `countryNames` as data attributes and reading them from `e.currentTarget` inside a single shared handler. +3. **Create a path generator** — `geoPath().projection(projection)`. Memoise. -With stable `style` references, `memo(Geography)` will correctly skip re-renders for countries whose data has not changed, eliminating the state-update race condition and the unnecessary rendering of ~200 SVG paths on every mouse-move event. +4. **Render countries** — Map over the GeoJSON features and render a `` element for each country. Use the `ISO_NUMERIC_TO_ALPHA2` lookup (already exists in `frontend/src/data/isoNumericToAlpha2.ts`) to translate the numeric feature id to the alpha-2 code expected by the `countries` prop. -**Files to change:** -- `frontend/src/components/WorldMap.tsx` — memoize style objects and stabilize event handlers. -- No changes needed in `react-simple-maps-main/src/components/Geography.js`. +5. **Preserve colour coding** — Continue using `getBanCountColor()` from `frontend/src/utils/mapColors.ts` to compute each country's fill colour based on its ban count and the three threshold props. + +6. **Preserve ban-count labels** — For every country with `count > 0`, compute the centroid with `pathGenerator.centroid(feature)` and render a `` element at that position showing the count. Countries with zero bans must remain blank and transparent (no fill, no label). + +7. **Preserve country selection** — Clicking a country calls `onSelectCountry` with the alpha-2 code (or `null` to deselect). The selected country must receive a distinct brand fill colour, matching the current behaviour. + +8. **Preserve hover tooltip** — On `mouseenter` / `mousemove` / `mouseleave`, show/hide a tooltip portal (`createPortal` into `document.body`) displaying the country name and ban count. Use the same Fluent UI styled tooltip div that the current implementation uses. + +9. **Preserve keyboard accessibility** — Each country with a known alpha-2 code must have `role="button"`, `tabIndex={0}`, an `aria-label` (`"CC: N ban(s)"`), and `aria-pressed` when selected. `Enter` and `Space` must trigger selection/deselection. + +10. **Use a `viewBox`-based responsive SVG** — Set `viewBox="0 0 {width} {height}"` and `style={{ width: "100%", height: "auto" }}` so the map scales with its container, matching the reference project's approach. + +--- + +#### Task 3 — Implement zoom and pan without `react-simple-maps` [DONE] + +The current implementation relies on `ZoomableGroup` from react-simple-maps for zoom/pan. Reimplement this using a `` wrapper with an SVG `transform` attribute driven by React state: + +1. **State:** Track `zoom` (number, 1–8) and `center` (translate offset `[x, y]`). + +2. **Zoom controls:** Keep the three overlay buttons (Zoom In `+`, Zoom Out `−`, Reset `⟲`) in the top-right corner. Each button adjusts the `zoom` state by ±0.5, clamped to `[1, 8]`. Reset sets zoom to 1 and center to `[0, 0]`. + +3. **Mouse-wheel zoom:** Attach a `wheel` event handler to the SVG that increments/decrements zoom on scroll, zooming toward the cursor position. + +4. **Click-and-drag pan:** Track `mousedown` → `mousemove` → `mouseup` on the SVG to translate the `center` offset. Only pan when the drag exceeds a small threshold (e.g. 3 px) to avoid conflicting with country click events. + +5. **Touch support (stretch goal):** Optionally support pinch-to-zoom and touch-drag for tablet users. + +6. **Apply transform:** Wrap all `` and `` elements in a `` group. Alternatively, use `d3-zoom` if a more robust implementation is preferred, but keep React as the rendering layer (no d3 DOM manipulation). + +--- + +#### Task 4 — Update hover and selection styles to use CSS transitions [DONE] + +The reference project applies hover highlights via CSS classes (`.country`, `.country.hovered`) with CSS `transition` instead of the react-simple-maps `style={{ default, hover, pressed }}` object. Adopt the same approach: + +- Define CSS classes (or Fluent UI `makeStyles` rules) for default, hovered, and selected states. +- Apply the correct class based on component state (`isSelected`, `isHovered`). +- Use a CSS `transition` on `fill` and `stroke` for a smooth 150 ms highlight effect. +- This avoids the react-simple-maps per-geography style object entirely. + +Ensure the selected state still uses `tokens.colorBrandBackground` / `tokens.colorBrandBackgroundHover` / `tokens.colorBrandBackgroundPressed` from Fluent UI so the map integrates visually with the rest of the application. + +--- + +#### Task 5 — Update the WorldMap unit test [DONE] + +The existing test at `frontend/src/components/__tests__/WorldMap.test.tsx` mocks `react-simple-maps`. After the migration those mocks are invalid. Update the test: + +1. **Remove the `vi.mock("react-simple-maps", ...)` block.** + +2. **Mock the TopoJSON data instead.** Since the new implementation imports `world-atlas/countries-110m.json` directly, mock that module to return a minimal TopoJSON object containing a single country feature (e.g. id `"840"` for the US). Use `topojson-client`'s `feature()` to verify the mock produces a valid GeoJSON feature. + +3. **Keep the same assertions:** tooltip appears on hover with country name and ban count, tooltip disappears on mouse leave, country element has correct ARIA attributes (`role="button"`, `aria-label`, `aria-pressed`). + +4. **Verify zoom controls render:** assert that the three zoom buttons (Zoom In, Zoom Out, Reset) are present and have the correct `aria-label` values. + +5. Also verify that tests in `MapPage.test.tsx` and `HistoryPage.test.tsx` still pass (they mock `WorldMap` at the component level so they should be unaffected, but confirm). + +--- + +#### Task 6 — Remove CDN dependency and verify offline capability [DONE] + +The old implementation fetched geography data from `https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json` at runtime. The new implementation bundles the data via the `world-atlas` npm package, so: + +1. Delete the `GEO_URL` constant. +2. Confirm the TopoJSON file is included in the Vite bundle (imported as a JSON module). +3. Verify the map renders correctly without any network request for geography data (check the browser network tab or write a test that asserts no fetch calls are made for the old CDN URL). + +--- + +#### Task 7 — Final integration smoke test [DONE] + +After all changes, manually verify the following against the feature specification in `Docs/Features.md` §4: + +- Countries are colour-coded by ban count (transparent → green → yellow → red) using smooth interpolation. +- Ban count numbers are displayed centred inside each country that has bans. +- Countries with zero bans are transparent with no label. +- Clicking a country filters the companion ban table below. +- Clicking the same country again deselects it. +- Zoom in / zoom out / reset buttons work correctly (range 1×–8×). +- Mouse-wheel zoom and click-drag pan work. +- Tooltip appears on hover showing country name and localised ban count. +- Keyboard navigation works (Tab to focus, Enter/Space to toggle selection). +- The map is responsive and scales with the container width. +- Time-range selector on `MapPage` still updates the map data correctly. +- Colour thresholds from settings are applied (thresholdLow, thresholdMedium, thresholdHigh props). +- Run `npm run test` — all existing tests pass. +- Run `npm run build` — production build succeeds with no errors or warnings. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 35bb71d..2aba3b3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,30 +1,33 @@ { "name": "bangui-frontend", - "version": "0.9.14", + "version": "0.9.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bangui-frontend", - "version": "0.9.14", + "version": "0.9.15", "dependencies": { "@fluentui/react-components": "^9.55.0", "@fluentui/react-icons": "^2.0.257", - "@types/react-simple-maps": "^3.0.6", + "d3-geo": "^3.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", - "react-simple-maps": "^3.0.0", - "recharts": "^3.8.0" + "recharts": "^3.8.0", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2" }, "devDependencies": { "@eslint/js": "^9.13.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/d3-geo": "^3.1.0", "@types/node": "^25.3.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/topojson-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/parser": "^8.13.0", "@vitejs/plugin-react": "^4.3.3", @@ -3565,23 +3568,15 @@ "license": "MIT" }, "node_modules/@types/d3-geo": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz", - "integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" } }, - "node_modules/@types/d3-interpolate": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz", - "integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "^2" - } - }, "node_modules/@types/d3-path": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", @@ -3597,12 +3592,6 @@ "@types/d3-time": "*" } }, - "node_modules/@types/d3-selection": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz", - "integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==", - "license": "MIT" - }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -3624,16 +3613,6 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, - "node_modules/@types/d3-zoom": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz", - "integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "^2", - "@types/d3-selection": "^2" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3652,6 +3631,7 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -3696,16 +3676,25 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/react-simple-maps": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz", - "integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==", + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/d3-geo": "^2", - "@types/d3-zoom": "^2", "@types/geojson": "*", - "@types/react": "*" + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" } }, "node_modules/@types/use-sync-external-store": { @@ -4476,28 +4465,6 @@ "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", "license": "BSD-3-Clause" }, - "node_modules/d3-dispatch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", - "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-drag": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz", - "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1 - 2", - "d3-selection": "2" - } - }, - "node_modules/d3-ease": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz", - "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==", - "license": "BSD-3-Clause" - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -4508,12 +4475,15 @@ } }, "node_modules/d3-geo": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", - "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", - "license": "BSD-3-Clause", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", "dependencies": { - "d3-array": "^2.5.0" + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" } }, "node_modules/d3-interpolate": { @@ -4550,12 +4520,6 @@ "node": ">=12" } }, - "node_modules/d3-selection": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", - "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==", - "license": "BSD-3-Clause" - }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -4592,41 +4556,6 @@ "node": ">=12" } }, - "node_modules/d3-timer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", - "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-transition": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz", - "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1 - 2", - "d3-dispatch": "1 - 2", - "d3-ease": "1 - 2", - "d3-interpolate": "1 - 2", - "d3-timer": "1 - 2" - }, - "peerDependencies": { - "d3-selection": "2" - } - }, - "node_modules/d3-zoom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz", - "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1 - 2", - "d3-drag": "2", - "d3-interpolate": "1 - 2", - "d3-selection": "2", - "d3-transition": "2" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -5745,16 +5674,6 @@ "dev": true, "license": "MIT" }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -5982,18 +5901,6 @@ "license": "MIT", "peer": true }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6110,23 +6017,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-simple-maps": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz", - "integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==", - "license": "MIT", - "dependencies": { - "d3-geo": "^2.0.2", - "d3-selection": "^2.0.0", - "d3-zoom": "^2.0.0", - "topojson-client": "^3.1.0" - }, - "peerDependencies": { - "prop-types": "^15.7.2", - "react": "^16.8.0 || 17.x || 18.x", - "react-dom": "^16.8.0 || 17.x || 18.x" - } - }, "node_modules/recharts": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", @@ -7516,6 +7406,12 @@ "node": ">=0.10.0" } }, + "node_modules/world-atlas": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz", + "integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==", + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 27ebe1e..68e61d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,14 +17,17 @@ "dependencies": { "@fluentui/react-components": "^9.55.0", "@fluentui/react-icons": "^2.0.257", - "@types/react-simple-maps": "^3.0.6", + "d3-geo": "^3.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", - "react-simple-maps": "^3.0.0", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2", "recharts": "^3.8.0" }, "devDependencies": { + "@types/d3-geo": "^3.1.0", + "@types/topojson-client": "^3.0.0", "@eslint/js": "^9.13.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx index d9b4871..ab4952e 100644 --- a/frontend/src/components/WorldMap.tsx +++ b/frontend/src/components/WorldMap.tsx @@ -1,32 +1,42 @@ /** * 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. + * Uses a local TopoJSON bundle and d3-geo for projection, path generation, + * and native SVG pan/zoom behaviour. */ import { createPortal } from "react-dom"; -import { useCallback, useMemo, useState } from "react"; -import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Button, makeStyles, tokens } from "@fluentui/react-components"; +import { geoMercator, geoPath, type GeoPath } from "d3-geo"; +import { feature } from "topojson-client"; +import type { + Feature, + FeatureCollection, + GeoJsonProperties, + Geometry, +} from "geojson"; +import type { + GeometryCollection as TopoGeometryCollection, + Topology, +} from "topojson-specification"; +import worldData from "world-atlas/countries-110m.json"; import { useCardStyles } from "../theme/commonStyles"; -import type { CSSProperties } from "react"; -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) -// --------------------------------------------------------------------------- - -const GEO_URL = - "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; - -// --------------------------------------------------------------------------- -// Styles -// --------------------------------------------------------------------------- +const MAP_WIDTH = 800; +const MAP_HEIGHT = 400; +const MIN_ZOOM = 1; +const MAX_ZOOM = 8; +const ZOOM_STEP = 0.5; +const PAN_THRESHOLD = 3; const useStyles = makeStyles({ mapWrapper: { @@ -34,6 +44,25 @@ const useStyles = makeStyles({ position: "relative", overflow: "hidden", }, + svg: { + width: "100%", + height: "auto", + touchAction: "none", + }, + country: { + transition: "fill 150ms ease, stroke 150ms ease", + stroke: tokens.colorNeutralStroke2, + strokeWidth: 0.75, + fill: "var(--country-fill)", + outline: "none", + cursor: "pointer", + }, + countryHovered: { + fill: "var(--country-hover-fill)", + }, + countrySelected: { + fill: "var(--country-selected-fill)", + }, countLabel: { fontSize: "9px", fontWeight: "600", @@ -74,212 +103,21 @@ const useStyles = makeStyles({ }, }); -// --------------------------------------------------------------------------- -// GeoLayer — must be rendered inside ComposableMap to access map context -// --------------------------------------------------------------------------- +type TopoJsonTopology = Topology & { + objects: { + countries: TopoGeometryCollection; + }; +}; -interface GeoLayerProps { - countries: Record; - countryNames?: Record; - selectedCountry: string | null; - onSelectCountry: (cc: string | null) => void; - thresholdLow: number; - thresholdMedium: number; - thresholdHigh: number; -} +type TooltipState = { + cc: string; + count: number; + name: string; + x: number; + y: number; +} | null; -function GeoLayer({ - countries, - countryNames, - selectedCountry, - onSelectCountry, - thresholdLow, - thresholdMedium, - thresholdHigh, -}: GeoLayerProps): React.JSX.Element { - const styles = useStyles(); - const { geographies, path } = useGeographies({ geography: GEO_URL }); - - const [tooltip, setTooltip] = useState< - | { - cc: string; - count: number; - name: string; - x: number; - y: number; - } - | null - >(null); - - const handleClick = useCallback( - (cc: string | null): void => { - onSelectCountry(selectedCountry === cc ? null : cc); - }, - [selectedCountry, onSelectCountry], - ); - - // Stable event handlers — shared across all Geography components so - // React.memo is not defeated by new function references each render. - const handleMouseEnter = useCallback( - (e: React.MouseEvent): void => { - const cc = e.currentTarget.dataset.cc; - if (!cc) return; - const count = Number(e.currentTarget.dataset.count); - const name = e.currentTarget.dataset.name ?? cc; - setTooltip({ cc, count, name, x: e.clientX, y: e.clientY }); - }, - [], - ); - - const handleMouseMove = useCallback( - (e: React.MouseEvent): void => { - setTooltip((current) => - current - ? { ...current, x: e.clientX, y: e.clientY } - : current, - ); - }, - [], - ); - - const handleMouseLeave = useCallback((): void => { - setTooltip(null); - }, []); - - // Memoize style objects so Geography's React.memo can skip re-renders - // when only the tooltip position changes. - type GeoStyle = { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties }; - const styleMap = useMemo(() => { - const map: Record = {}; - for (const geo of geographies as { rsmKey: string; id: string | number }[]) { - 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 fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh); - map[geo.rsmKey] = { - default: { - fill: isSelected ? tokens.colorBrandBackground : fillColor, - stroke: tokens.colorNeutralStroke2, - strokeWidth: 0.75, - outline: "none", - }, - hover: { - fill: isSelected - ? tokens.colorBrandBackgroundHover - : cc && count > 0 - ? tokens.colorNeutralBackground3 - : fillColor, - stroke: tokens.colorNeutralStroke1, - strokeWidth: 1, - outline: "none", - }, - pressed: { - fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, - stroke: tokens.colorBrandStroke1, - strokeWidth: 1, - outline: "none", - }, - }; - } - return map; - }, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]); - - 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; - - 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; - - // 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 ( - - { - if (cc) handleClick(cc); - }} - onKeyDown={(e): void => { - if (cc && (e.key === "Enter" || e.key === " ")) { - e.preventDefault(); - handleClick(cc); - } - }} - onMouseEnter={handleMouseEnter} - onMouseMove={handleMouseMove} - onMouseLeave={handleMouseLeave} - data-cc={cc ?? undefined} - data-count={cc ? String(count) : undefined} - data-name={cc ? (countryNames?.[cc] ?? cc) : undefined} - style={styleMap[geo.rsmKey]} - /> - {count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && ( - - {count} - - )} - - ); - }, - )} - - {tooltip && - createPortal( -
- {tooltip.name} - - {tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""} - -
, - document.body, - )} - - ); -} - -// --------------------------------------------------------------------------- -// WorldMap — public component -// --------------------------------------------------------------------------- - -export interface WorldMapProps { +interface WorldMapProps { /** ISO alpha-2 country code → ban count. */ countries: Record; /** Optional mapping from country code to display name. */ @@ -307,21 +145,143 @@ export function WorldMap({ }: WorldMapProps): React.JSX.Element { const styles = useStyles(); const cardStyles = useCardStyles(); - const [zoom, setZoom] = useState(1); + const [zoom, setZoom] = useState(MIN_ZOOM); const [center, setCenter] = useState<[number, number]>([0, 0]); + const [hoveredCountry, setHoveredCountry] = useState(null); + const [tooltip, setTooltip] = useState(null); - const handleZoomIn = (): void => { - setZoom((z) => Math.min(z + 0.5, 8)); - }; + const zoomRef = useRef(zoom); + const centerRef = useRef<[number, number]>(center); + const dragStateRef = useRef<{ + active: boolean; + startX: number; + startY: number; + startCenter: [number, number]; + moved: boolean; + } | null>(null); + const clickSuppressedRef = useRef(false); - const handleZoomOut = (): void => { - setZoom((z) => Math.max(z - 0.5, 1)); - }; + useEffect(() => { + zoomRef.current = zoom; + }, [zoom]); - const handleResetView = (): void => { - setZoom(1); + useEffect(() => { + centerRef.current = center; + }, [center]); + + const topology = useMemo(() => worldData as unknown as TopoJsonTopology, []); + + const geoJson = useMemo( + () => + feature(topology, topology.objects.countries) as FeatureCollection< + Geometry, + GeoJsonProperties + >, + [topology], + ); + + const projection = useMemo( + () => geoMercator().fitSize([MAP_WIDTH, MAP_HEIGHT], geoJson), + [geoJson], + ); + + const pathGenerator = useMemo>>( + () => geoPath().projection(projection), + [projection], + ); + + const countryFeatures = useMemo( + () => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null), + [geoJson.features], + ); + + const clampZoom = useCallback((value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM), []); + + const handleCountrySelect = useCallback( + (cc: string | null): void => { + if (clickSuppressedRef.current) { + return; + } + + onSelectCountry(selectedCountry === cc ? null : cc); + }, + [onSelectCountry, selectedCountry], + ); + + const handlePointerDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return; + + event.currentTarget.setPointerCapture(event.pointerId); + dragStateRef.current = { + active: true, + startX: event.clientX, + startY: event.clientY, + startCenter: centerRef.current, + moved: false, + }; + clickSuppressedRef.current = false; + }, []); + + const handlePointerMove = useCallback((event: React.PointerEvent) => { + const drag = dragStateRef.current; + if (!drag?.active) return; + + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) { + drag.moved = true; + clickSuppressedRef.current = true; + } + + setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]); + }, []); + + const handlePointerUp = useCallback((event: React.PointerEvent) => { + const drag = dragStateRef.current; + if (!drag) return; + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + dragStateRef.current = null; + window.setTimeout(() => { + clickSuppressedRef.current = false; + }, 0); + }, []); + + const handleWheel = useCallback((event: React.WheelEvent) => { + event.preventDefault(); + + const currentZoom = zoomRef.current; + const desiredZoom = clampZoom(currentZoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)); + if (desiredZoom === currentZoom) { + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + const svgX = (event.clientX - rect.left - centerRef.current[0]) / currentZoom; + const svgY = (event.clientY - rect.top - centerRef.current[1]) / currentZoom; + + setZoom(desiredZoom); + setCenter([ + centerRef.current[0] - svgX * (desiredZoom - currentZoom), + centerRef.current[1] - svgY * (desiredZoom - currentZoom), + ]); + }, [clampZoom]); + + const handleZoomIn = useCallback(() => { + setZoom((value) => clampZoom(value + ZOOM_STEP)); + }, [clampZoom]); + + const handleZoomOut = useCallback(() => { + setZoom((value) => clampZoom(value - ZOOM_STEP)); + }, [clampZoom]); + + const handleResetView = useCallback(() => { + setZoom(MIN_ZOOM); setCenter([0, 0]); - }; + }, []); return (
- {/* Zoom controls */}
- - { - setZoom(newZoom); - setCenter(coordinates); - }} - minZoom={1} - maxZoom={8} - > - - - + + {countryFeatures.map((featureItem) => { + const rawId = featureItem.id; + const numericId = String(Number(rawId)); + const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; + const count = cc !== null ? countries[cc] ?? 0 : 0; + const isSelected = cc !== null && selectedCountry === cc; + const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh); + const pathString = pathGenerator(featureItem) ?? ""; + if (!pathString) { + return null; + } + + const centroid = pathGenerator.centroid(featureItem); + const [cx, cy] = centroid; + const isCentroidValid = Number.isFinite(cx) && Number.isFinite(cy); + + return ( + + { + if (cc) { + handleCountrySelect(cc); + } + }} + onKeyDown={(event): void => { + if (cc && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + handleCountrySelect(cc); + } + }} + onMouseEnter={(event): void => { + if (!cc) return; + setHoveredCountry(cc); + setTooltip({ + cc, + count, + name: countryNames?.[cc] ?? cc, + x: event.clientX, + y: event.clientY, + }); + }} + onMouseMove={(event): void => { + setTooltip((current) => + current + ? { ...current, x: event.clientX, y: event.clientY } + : current, + ); + }} + onMouseLeave={(): void => { + setHoveredCountry(null); + setTooltip(null); + }} + /> + {count > 0 && isCentroidValid && ( + + {count} + + )} + + ); + })} + + + + {tooltip && + createPortal( +
+ {tooltip.name} + + {tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""} + +
, + document.body, + )}
); } diff --git a/frontend/src/components/__tests__/WorldMap.test.tsx b/frontend/src/components/__tests__/WorldMap.test.tsx index 4d70cda..9980b76 100644 --- a/frontend/src/components/__tests__/WorldMap.test.tsx +++ b/frontend/src/components/__tests__/WorldMap.test.tsx @@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; -// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry. -vi.mock("react-simple-maps", () => ({ - ComposableMap: ({ children }: { children: React.ReactNode }) =>
{children}
, - ZoomableGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, - Geography: ({ children, ...props }: { children?: React.ReactNode } & Record) => {children}, - useGeographies: () => ({ - geographies: [{ rsmKey: "geo-1", id: 840 }], - path: { centroid: () => [10, 10] }, +vi.mock( + "world-atlas/countries-110m.json", + () => ({ + default: { + type: "Topology", + objects: { + countries: { + type: "GeometryCollection", + geometries: [ + { + type: "Polygon", + arcs: [[0]], + id: "840", + }, + ], + }, + }, + arcs: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], + transform: { + scale: [1, 1], + translate: [0, 0], + }, + }, }), -})); +); import { WorldMap } from "../WorldMap"; @@ -34,19 +49,20 @@ describe("WorldMap", () => { , ); - // Tooltip should not be present initially expect(screen.queryByRole("tooltip")).toBeNull(); - // Country map area is exposed as an accessible button with an accurate label const countryButton = screen.getByRole("button", { name: "US: 42 bans" }); expect(countryButton).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Zoom in/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Zoom out/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Reset view/i })).toBeInTheDocument(); + fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 }); const tooltip = screen.getByRole("tooltip"); expect(tooltip).toHaveTextContent("United States"); expect(tooltip).toHaveTextContent("42 bans"); - expect(tooltip).toHaveStyle({ left: "22px", top: "22px" }); fireEvent.mouseLeave(countryButton); expect(screen.queryByRole("tooltip")).toBeNull(); diff --git a/frontend/src/data/isoNumericToAlpha2.ts b/frontend/src/data/isoNumericToAlpha2.ts index cca5dc2..b623bfa 100644 --- a/frontend/src/data/isoNumericToAlpha2.ts +++ b/frontend/src/data/isoNumericToAlpha2.ts @@ -8,6 +8,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "4": "AF", "8": "AL", + "10": "AQ", "12": "DZ", "16": "AS", "20": "AD", @@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "148": "TD", "152": "CL", "156": "CN", + "158": "TW", "162": "CX", "166": "CC", "170": "CO", @@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "250": "FR", "254": "GF", "258": "PF", + "260": "TF", "262": "DJ", "266": "GA", "268": "GE", @@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "372": "IE", "376": "IL", "380": "IT", + "384": "CI", "388": "JM", "392": "JP", "398": "KZ", diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 0235e1d..04823da 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -8,6 +8,8 @@ /* Bundler mode */ "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", From 7d09b78437006515d52285e50989e68039cf0ef3 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 5 Apr 2026 18:54:02 +0200 Subject: [PATCH 03/11] chore: release v0.9.16 --- Docker/VERSION | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Docker/VERSION b/Docker/VERSION index 6762819..f00c157 100644 --- a/Docker/VERSION +++ b/Docker/VERSION @@ -1 +1 @@ -v0.9.15 +v0.9.16 diff --git a/frontend/package.json b/frontend/package.json index 68e61d8..ac313e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "bangui-frontend", "private": true, - "version": "0.9.15", + "version": "0.9.16", "description": "BanGUI frontend — fail2ban web management interface", "type": "module", "scripts": { From ffaa14f8645ebb74d57ae3cd661651871817ad49 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 5 Apr 2026 20:21:54 +0200 Subject: [PATCH 04/11] Switch dashboard/map/history views to archive source for long-term data Update fail2ban dbpurgeage to 648000 and history sync backfill/pagination for archive-based 7.5 day history. --- Docker/fail2ban-dev-config/README.md | 5 +++ backend/app/routers/dashboard.py | 10 +++--- backend/app/routers/history.py | 4 +-- backend/app/tasks/history_sync.py | 4 +-- backend/tests/test_routers/test_dashboard.py | 27 ++++++++++++++++ backend/tests/test_tasks/test_history_sync.py | 32 ++++++++++++++++++- fail2ban-master/config/fail2ban.conf | 2 +- frontend/src/api/dashboard.ts | 12 +++++++ frontend/src/api/history.ts | 1 + frontend/src/api/map.ts | 4 +++ frontend/src/components/BanTable.tsx | 8 +++-- frontend/src/components/BanTrendChart.tsx | 5 ++- frontend/src/hooks/useBanTrend.ts | 5 +-- frontend/src/hooks/useBans.ts | 9 +++--- frontend/src/hooks/useDashboardCountryData.ts | 5 +-- frontend/src/hooks/useMapData.ts | 5 +-- frontend/src/pages/DashboardPage.tsx | 27 ++++++++++------ frontend/src/pages/HistoryPage.tsx | 10 +++++- frontend/src/pages/MapPage.tsx | 7 +++- .../src/pages/__tests__/HistoryPage.test.tsx | 3 +- frontend/src/types/history.ts | 1 + 21 files changed, 149 insertions(+), 37 deletions(-) diff --git a/Docker/fail2ban-dev-config/README.md b/Docker/fail2ban-dev-config/README.md index 6ecaf56..6422e00 100644 --- a/Docker/fail2ban-dev-config/README.md +++ b/Docker/fail2ban-dev-config/README.md @@ -78,6 +78,11 @@ Chains steps 1–3 automatically with appropriate sleep intervals. Inside the container the log file is mounted at `/remotelogs/bangui/auth.log` (see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`). +BanGUI also extends fail2ban history retention for archive backfill. In +the development config `fail2ban/fail2ban.conf` the database purge age is +set to `648000` seconds (7.5 days) so the first archive sync can recover a +full 7-day window before fail2ban purges old rows. + To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`: ```ini diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 2179ac5..af90287 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -12,7 +12,7 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table, from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: import aiohttp @@ -83,7 +83,7 @@ async def get_dashboard_bans( request: Request, _auth: AuthDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), - source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), + source: Literal["fail2ban", "archive"] = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), page: int = Query(default=1, ge=1, description="1-based page number."), page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."), origin: BanOrigin | None = Query( @@ -137,7 +137,7 @@ async def get_bans_by_country( request: Request, _auth: AuthDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), - source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), + source: Literal["fail2ban", "archive"] = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", @@ -185,7 +185,7 @@ async def get_ban_trend( request: Request, _auth: AuthDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), - source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), + source: Literal["fail2ban", "archive"] = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", @@ -235,7 +235,7 @@ async def get_bans_by_jail( request: Request, _auth: AuthDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), - source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), + source: Literal["fail2ban", "archive"] = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py index 74228fe..bc0f214 100644 --- a/backend/app/routers/history.py +++ b/backend/app/routers/history.py @@ -15,7 +15,7 @@ Routes from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: import aiohttp @@ -56,7 +56,7 @@ async def get_history( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", ), - source: str = Query( + source: Literal["fail2ban", "archive"] = Query( default="fail2ban", description="Data source: 'fail2ban' or 'archive'.", ), diff --git a/backend/app/tasks/history_sync.py b/backend/app/tasks/history_sync.py index b6ea3d3..17d48fd 100644 --- a/backend/app/tasks/history_sync.py +++ b/backend/app/tasks/history_sync.py @@ -26,7 +26,7 @@ JOB_ID: str = "history_sync" HISTORY_SYNC_INTERVAL: int = 300 #: Backfill window when archive is empty (seconds). -BACKFILL_WINDOW: int = 7 * 86400 +BACKFILL_WINDOW: int = 648000 async def _get_last_archive_ts(db) -> int | None: @@ -50,7 +50,7 @@ async def _run_sync(app: FastAPI) -> None: log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW) per_page = 500 - next_since = last_ts + next_since = last_ts + 1 total_synced = 0 while True: diff --git a/backend/tests/test_routers/test_dashboard.py b/backend/tests/test_routers/test_dashboard.py index 20bcade..30a8c89 100644 --- a/backend/tests/test_routers/test_dashboard.py +++ b/backend/tests/test_routers/test_dashboard.py @@ -428,6 +428,15 @@ class TestBansByCountry: called_range = mock_fn.call_args[0][1] assert called_range == "7d" + async def test_invalid_source_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid source value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/by-country?source=invalid" + ) + assert response.status_code == 422 + async def test_empty_window_returns_empty_response( self, dashboard_client: AsyncClient ) -> None: @@ -722,6 +731,15 @@ class TestBanTrend: ) assert response.status_code == 422 + async def test_invalid_source_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid source value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/trend?source=invalid" + ) + assert response.status_code == 422 + async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None: """Empty bucket list is serialised correctly.""" from app.models.ban import BanTrendResponse @@ -857,6 +875,15 @@ class TestBansByJail: ) assert response.status_code == 422 + async def test_invalid_source_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid source value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/by-jail?source=invalid" + ) + assert response.status_code == 422 + async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None: """Empty jails list is serialised correctly.""" from app.models.ban import BansByJailResponse diff --git a/backend/tests/test_tasks/test_history_sync.py b/backend/tests/test_tasks/test_history_sync.py index c9e1c44..de167d6 100644 --- a/backend/tests/test_tasks/test_history_sync.py +++ b/backend/tests/test_tasks/test_history_sync.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from app.tasks import history_sync @@ -27,3 +27,33 @@ class TestHistorySyncTask: called_args, called_kwargs = fake_scheduler.add_job.call_args assert called_kwargs["id"] == history_sync.JOB_ID assert called_kwargs["kwargs"]["app"] == app + + async def test_backfill_window_is_7_5_days(self) -> None: + assert history_sync.BACKFILL_WINDOW == 648000 + + async def test_sync_uses_strict_since_after_restart(self) -> None: + fake_app = type("FakeApp", (), {})() + fake_app.state = type("FakeState", (), {})() + fake_app.state.settings = type("FakeSettings", (), {})() + fake_app.state.settings.fail2ban_socket = "/tmp/fake.sock" + + fake_app.state.db = MagicMock() + + async def fake_get_history_page(*, db_path: str, since: int, page: int, page_size: int, **kwargs): + assert since == 1001 + return [], 0 + + async def fake_get_fail2ban_db_path(socket_path: str) -> str: + return "/tmp/fake.sqlite3" + + with patch( + "app.tasks.history_sync._get_last_archive_ts", + new=AsyncMock(return_value=1000), + ), patch( + "app.tasks.history_sync.get_fail2ban_db_path", + new=fake_get_fail2ban_db_path, + ), patch( + "app.tasks.history_sync.fail2ban_db_repo.get_history_page", + new=fake_get_history_page, + ): + await history_sync._run_sync(fake_app) diff --git a/fail2ban-master/config/fail2ban.conf b/fail2ban-master/config/fail2ban.conf index fd6baeb..080031c 100644 --- a/fail2ban-master/config/fail2ban.conf +++ b/fail2ban-master/config/fail2ban.conf @@ -72,7 +72,7 @@ dbfile = /var/lib/fail2ban/fail2ban.sqlite3 # Options: dbpurgeage # Notes.: Sets age at which bans should be purged from the database # Values: [ SECONDS ] Default: 86400 (24hours) -dbpurgeage = 1d +dbpurgeage = 648000 # Options: dbmaxmatches # Notes.: Number of matches stored in database per ticket (resolvable via diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 90309d8..61e429e 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -42,6 +42,7 @@ export async function fetchBans( page = 1, pageSize = 100, origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): Promise { const params = new URLSearchParams({ range, @@ -51,6 +52,9 @@ export async function fetchBans( if (origin !== "all") { params.set("origin", origin); } + if (source !== "fail2ban") { + params.set("source", source); + } return get(`${ENDPOINTS.dashboardBans}?${params.toString()}`); } @@ -66,11 +70,15 @@ export async function fetchBans( export async function fetchBanTrend( range: TimeRange, origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): Promise { const params = new URLSearchParams({ range }); if (origin !== "all") { params.set("origin", origin); } + if (source !== "fail2ban") { + params.set("source", source); + } return get(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`); } @@ -86,10 +94,14 @@ export async function fetchBanTrend( export async function fetchBansByJail( range: TimeRange, origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): Promise { const params = new URLSearchParams({ range }); if (origin !== "all") { params.set("origin", origin); } + if (source !== "fail2ban") { + params.set("source", source); + } return get(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`); } diff --git a/frontend/src/api/history.ts b/frontend/src/api/history.ts index e318a5a..f239317 100644 --- a/frontend/src/api/history.ts +++ b/frontend/src/api/history.ts @@ -21,6 +21,7 @@ export async function fetchHistory( if (query.origin) params.set("origin", query.origin); if (query.jail) params.set("jail", query.jail); if (query.ip) params.set("ip", query.ip); + if (query.source) params.set("source", query.source); if (query.page !== undefined) params.set("page", String(query.page)); if (query.page_size !== undefined) params.set("page_size", String(query.page_size)); diff --git a/frontend/src/api/map.ts b/frontend/src/api/map.ts index e6b8eda..5405995 100644 --- a/frontend/src/api/map.ts +++ b/frontend/src/api/map.ts @@ -17,10 +17,14 @@ import type { BanOriginFilter } from "../types/ban"; export async function fetchBansByCountry( range: TimeRange = "24h", origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): Promise { const params = new URLSearchParams({ range }); if (origin !== "all") { params.set("origin", origin); } + if (source !== "fail2ban") { + params.set("source", source); + } return get(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`); } diff --git a/frontend/src/components/BanTable.tsx b/frontend/src/components/BanTable.tsx index bff6164..85879ac 100644 --- a/frontend/src/components/BanTable.tsx +++ b/frontend/src/components/BanTable.tsx @@ -46,6 +46,10 @@ interface BanTableProps { * Changing this value triggers a re-fetch and resets to page 1. */ origin?: BanOriginFilter; + /** + * Data source used for the table query. + */ + source?: "fail2ban" | "archive"; } // --------------------------------------------------------------------------- @@ -186,9 +190,9 @@ function buildBanColumns(styles: ReturnType): TableColumnDefin * @param props.timeRange - Active time-range preset from the parent page. * @param props.origin - Active origin filter from the parent page. */ -export function BanTable({ timeRange, origin = "all" }: BanTableProps): React.JSX.Element { +export function BanTable({ timeRange, origin = "all", source = "fail2ban" }: BanTableProps): React.JSX.Element { const styles = useStyles(); - const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin); + const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin, source); const banColumns = buildBanColumns(styles); diff --git a/frontend/src/components/BanTrendChart.tsx b/frontend/src/components/BanTrendChart.tsx index 9f56586..32dbfaa 100644 --- a/frontend/src/components/BanTrendChart.tsx +++ b/frontend/src/components/BanTrendChart.tsx @@ -53,6 +53,8 @@ interface BanTrendChartProps { timeRange: TimeRange; /** Origin filter controlling which bans are included. */ origin: BanOriginFilter; + /** Data source used for the chart. */ + source?: "fail2ban" | "archive"; } /** Internal chart data point shape. */ @@ -188,9 +190,10 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null { export function BanTrendChart({ timeRange, origin, + source = "fail2ban", }: BanTrendChartProps): React.JSX.Element { const styles = useStyles(); - const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin); + const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin, source); const isEmpty = buckets.every((b) => b.count === 0); const entries = buildEntries(buckets, timeRange); diff --git a/frontend/src/hooks/useBanTrend.ts b/frontend/src/hooks/useBanTrend.ts index 2e45660..b258081 100644 --- a/frontend/src/hooks/useBanTrend.ts +++ b/frontend/src/hooks/useBanTrend.ts @@ -42,6 +42,7 @@ export interface UseBanTrendResult { export function useBanTrend( timeRange: TimeRange, origin: BanOriginFilter, + source: "fail2ban" | "archive" = "fail2ban", ): UseBanTrendResult { const [buckets, setBuckets] = useState([]); const [bucketSize, setBucketSize] = useState("1h"); @@ -58,7 +59,7 @@ export function useBanTrend( setIsLoading(true); setError(null); - fetchBanTrend(timeRange, origin) + fetchBanTrend(timeRange, origin, source) .then((data) => { if (controller.signal.aborted) return; setBuckets(data.buckets); @@ -73,7 +74,7 @@ export function useBanTrend( setIsLoading(false); } }); - }, [timeRange, origin]); + }, [timeRange, origin, source]); useEffect(() => { load(); diff --git a/frontend/src/hooks/useBans.ts b/frontend/src/hooks/useBans.ts index 9e36f45..c51471c 100644 --- a/frontend/src/hooks/useBans.ts +++ b/frontend/src/hooks/useBans.ts @@ -44,6 +44,7 @@ export interface UseBansResult { export function useBans( timeRange: TimeRange, origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): UseBansResult { const [banItems, setBanItems] = useState([]); const [total, setTotal] = useState(0); @@ -51,16 +52,16 @@ export function useBans( const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Reset page when time range or origin filter changes. + // Reset page when time range, origin filter, or source changes. useEffect(() => { setPage(1); - }, [timeRange, origin]); + }, [timeRange, origin, source]); const doFetch = useCallback(async (): Promise => { setLoading(true); setError(null); try { - const data = await fetchBans(timeRange, page, PAGE_SIZE, origin); + const data = await fetchBans(timeRange, page, PAGE_SIZE, origin, source); setBanItems(data.items); setTotal(data.total); } catch (err: unknown) { @@ -68,7 +69,7 @@ export function useBans( } finally { setLoading(false); } - }, [timeRange, page, origin]); + }, [timeRange, page, origin, source]); // Stable ref to the latest doFetch so the refresh callback is always current. const doFetchRef = useRef(doFetch); diff --git a/frontend/src/hooks/useDashboardCountryData.ts b/frontend/src/hooks/useDashboardCountryData.ts index 250fcff..3c9e625 100644 --- a/frontend/src/hooks/useDashboardCountryData.ts +++ b/frontend/src/hooks/useDashboardCountryData.ts @@ -48,6 +48,7 @@ export interface UseDashboardCountryDataResult { export function useDashboardCountryData( timeRange: TimeRange, origin: BanOriginFilter, + source: "fail2ban" | "archive" = "fail2ban", ): UseDashboardCountryDataResult { const [countries, setCountries] = useState>({}); const [countryNames, setCountryNames] = useState>({}); @@ -67,7 +68,7 @@ export function useDashboardCountryData( setIsLoading(true); setError(null); - fetchBansByCountry(timeRange, origin) + fetchBansByCountry(timeRange, origin, source) .then((data) => { if (controller.signal.aborted) return; setCountries(data.countries); @@ -85,7 +86,7 @@ export function useDashboardCountryData( setIsLoading(false); } }); - }, [timeRange, origin]); + }, [timeRange, origin, source]); useEffect(() => { load(); diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index d4537e6..f1d2f76 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -43,6 +43,7 @@ export interface UseMapDataResult { export function useMapData( range: TimeRange = "24h", origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): UseMapDataResult { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -64,7 +65,7 @@ export function useMapData( abortRef.current?.abort(); abortRef.current = new AbortController(); - fetchBansByCountry(range, origin) + fetchBansByCountry(range, origin, source) .then((resp) => { setData(resp); }) @@ -75,7 +76,7 @@ export function useMapData( setLoading(false); }); }, DEBOUNCE_MS); - }, [range, origin]); + }, [range, origin, source]); useEffect((): (() => void) => { load(); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index c22abcc..60921fa 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -7,7 +7,7 @@ */ import { useState } from "react"; -import { Text, makeStyles, tokens } from "@fluentui/react-components"; +import { Badge, Text, makeStyles, tokens } from "@fluentui/react-components"; import { BanTable } from "../components/BanTable"; import { BanTrendChart } from "../components/BanTrendChart"; import { ChartStateWrapper } from "../components/ChartStateWrapper"; @@ -71,8 +71,10 @@ export function DashboardPage(): React.JSX.Element { const [timeRange, setTimeRange] = useState("24h"); const [originFilter, setOriginFilter] = useState("all"); + const source = timeRange === "24h" ? "fail2ban" : "archive"; + const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } = - useDashboardCountryData(timeRange, originFilter); + useDashboardCountryData(timeRange, originFilter, source); const sectionStyles = useCommonSectionStyles(); @@ -86,12 +88,17 @@ export function DashboardPage(): React.JSX.Element { {/* ------------------------------------------------------------------ */} {/* Global filter bar */} {/* ------------------------------------------------------------------ */} - +
+ + + {source === "archive" ? "Archive (BanGUI DB)" : "Live (fail2ban DB)"} + +
{/* ------------------------------------------------------------------ */} {/* Ban Trend section */} @@ -103,7 +110,7 @@ export function DashboardPage(): React.JSX.Element {
- +
@@ -154,7 +161,7 @@ export function DashboardPage(): React.JSX.Element { {/* Ban table */}
- +
diff --git a/frontend/src/pages/HistoryPage.tsx b/frontend/src/pages/HistoryPage.tsx index debdb85..83de3fc 100644 --- a/frontend/src/pages/HistoryPage.tsx +++ b/frontend/src/pages/HistoryPage.tsx @@ -143,6 +143,7 @@ function areHistoryQueriesEqual( a.origin === b.origin && a.jail === b.jail && a.ip === b.ip && + a.source === b.source && a.page === b.page && a.page_size === b.page_size ); @@ -386,11 +387,12 @@ export function HistoryPage(): React.JSX.Element { const styles = useStyles(); // Filter state - const [range, setRange] = useState("24h"); + const [range, setRange] = useState("7d"); const [originFilter, setOriginFilter] = useState("all"); const [jailFilter, setJailFilter] = useState(""); const [ipFilter, setIpFilter] = useState(""); const [appliedQuery, setAppliedQuery] = useState({ + source: "archive", page_size: PAGE_SIZE, }); @@ -400,12 +402,15 @@ export function HistoryPage(): React.JSX.Element { const { items, total, page, loading, error, setPage, refresh } = useHistory(appliedQuery); + const sourceLabel = "Archive (BanGUI DB)"; + useEffect((): void => { const nextQuery: HistoryQuery = { range, origin: originFilter !== "all" ? originFilter : undefined, jail: jailFilter.trim() || undefined, ip: ipFilter.trim() || undefined, + source: "archive", page: 1, page_size: PAGE_SIZE, }; @@ -485,6 +490,9 @@ export function HistoryPage(): React.JSX.Element { setIpFilter(value); }} /> + + {sourceLabel} + {/* ---------------------------------------------------------------- */} diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index 1b3fbdc..60ce55f 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -98,8 +98,10 @@ export function MapPage(): React.JSX.Element { const PAGE_SIZE_OPTIONS = [25, 50, 100] as const; + const source = range === "24h" ? "fail2ban" : "archive"; + const { countries, countryNames, bans, total, loading, error, refresh } = - useMapData(range, originFilter); + useMapData(range, originFilter, source); const { thresholds: mapThresholds, @@ -163,6 +165,9 @@ export function MapPage(): React.JSX.Element { setSelectedCountry(null); }} /> + + {source === "archive" ? "Archive (BanGUI DB)" : "Live (fail2ban DB)"} +