Compare commits
11 Commits
ac4fd967aa
...
v0.9.16
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d09b78437 | |||
| 8e2bb5d3fb | |||
| bfe0daf754 | |||
| 13823b1182 | |||
| 7967191ccd | |||
| 470c29443c | |||
| 6f15e1fa24 | |||
| 487cb171f2 | |||
| 7789353690 | |||
| ccfcbc82c5 | |||
| 7626c9cb60 |
@@ -1 +1 @@
|
|||||||
v0.9.14
|
v0.9.16
|
||||||
|
|||||||
149
Docs/Tasks.md
149
Docs/Tasks.md
@@ -8,49 +8,128 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|||||||
|
|
||||||
## Open Issues
|
## Open Issues
|
||||||
|
|
||||||
1. Ban history durability and fail2ban DB size management
|
### Replace `react-simple-maps` with `d3-geo` in WorldMap
|
||||||
- status: completed
|
|
||||||
- description: BanGUI currently reads fail2ban history directly, but fail2ban's `dbpurgeage` may erase old history and can cause DB growth issues with long retention. Implement a BanGUI-native persistent archive and keep fail2ban DB short-lived.
|
|
||||||
- acceptance criteria:
|
|
||||||
- BanGUI can configure and fetch fail2ban `dbpurgeage` and `dbfile` from server API.
|
|
||||||
- Introduce periodic sync job that reads new fail2ban ban/unban events and writes them to BanGUI app DB.
|
|
||||||
- Use dedupe logic to avoid duplicate entries (unique constraint by `ip,jail,action,timestamp`).
|
|
||||||
- Add persistence policy settings (default 365 days) in UI and server config.
|
|
||||||
- Add backfill workflow on startup for last 7 days if archive empty.
|
|
||||||
- Existing history API endpoints must support both a `source` filter (`fail2ban`, `archive`) and time range.
|
|
||||||
- implementation notes:
|
|
||||||
- Add repository methods `archive_ban_event`, `get_archived_history(...)`, `purge_archived_history(age_seconds)`.
|
|
||||||
- Add periodic task in `backend/app/tasks/history_sync.py` triggered by scheduler.
|
|
||||||
- Extend `Backend/app/routers/history.py` to include endpoint `/api/history/archive`.
|
|
||||||
|
|
||||||
2. History retention and warning for bad configuration (done)
|
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/`.
|
||||||
- status: completed
|
|
||||||
- description: fail2ban may be configured with low `dbpurgeage` causing quick loss; user needs clear warning and safe defaults.
|
|
||||||
- acceptance criteria:
|
|
||||||
- On server settings load, if `dbpurgeage` < 86400, expose warning state in API.
|
|
||||||
- UI displays warning banner: "Current fail2ban purge age is under 24h; detailed history may be lost.".
|
|
||||||
- Allow user to increase `dbpurgeage` through server settings panel; sync to fail2ban using `set dbpurgeage`.
|
|
||||||
- Add tests for server service response and UI warning logic.
|
|
||||||
|
|
||||||
3. History access from existing BanGUI features
|
Reference: `Docs/Features.md` §4 (World Map View) for the full feature specification.
|
||||||
- status: completed
|
|
||||||
- description: Doors for dashboard and map data should use archived history to avoid data gaps.
|
|
||||||
- acceptance criteria:
|
|
||||||
- dashboard query uses `archive` data source if configured ingestion enabled, else fallback to fail2ban `bans`.
|
|
||||||
- world map grouping includes archived data and can aggregate `count` with timeframe filters.
|
|
||||||
- API and UI unit tests verify data source fallback.
|
|
||||||
|
|
||||||
4. Event-based sync enhancement (optional, high value)
|
**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.
|
||||||
- description: implement event-driven ingestion to avoid polling delay.
|
|
||||||
- acceptance criteria:
|
|
||||||
- Add fail2ban hook or systemd journal watcher to capture ban/unban events in real time.
|
|
||||||
- Recorded events store to BanGUI archive in transaction-safe manner.
|
|
||||||
- Add validation for event integrity and order.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### Task 1 — Swap npm dependencies [DONE]
|
||||||
|
|
||||||
|
Remove `react-simple-maps` and `@types/react-simple-maps` from `frontend/package.json`. Add the following packages that the new implementation requires:
|
||||||
|
|
||||||
|
- `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).
|
||||||
|
|
||||||
|
Run `npm install` and verify the lock file updates cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2 — Rewrite `WorldMap.tsx` to use d3-geo directly [DONE]
|
||||||
|
|
||||||
|
Rewrite the component so that it renders a plain `<svg>` with `<path>` elements generated by d3-geo instead of the react-simple-maps wrappers. The implementation should follow this approach (as seen in the reference project):
|
||||||
|
|
||||||
|
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. **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. **Create a path generator** — `geoPath().projection(projection)`. Memoise.
|
||||||
|
|
||||||
|
4. **Render countries** — Map over the GeoJSON features and render a `<path>` 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.
|
||||||
|
|
||||||
|
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 `<text>` 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 `<g>` 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 `<path>` and `<text>` elements in a `<g transform="translate(tx, ty) scale(zoom)">` 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.
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ Use Fluent UI React components as the building blocks. The following mapping sho
|
|||||||
|
|
||||||
| Element | Fluent component | Notes |
|
| Element | Fluent component | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. |
|
| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. Use clear pagination controls (page number + prev/next) and a page-size selector (25/50/100) for large result sets. |
|
||||||
| Stat cards | `DocumentCard` or custom `Stack` card | Dashboard status bar — server status, total bans, active jails. Use `Depth 4`. |
|
| Stat cards | `DocumentCard` or custom `Stack` card | Dashboard status bar — server status, total bans, active jails. Use `Depth 4`. |
|
||||||
| Status indicators | `Badge` / `Icon` + colour | Server online/offline, jail running/stopped/idle. |
|
| Status indicators | `Badge` / `Icon` + colour | Server online/offline, jail running/stopped/idle. |
|
||||||
| Country labels | Monospaced text + flag emoji or icon | Geo data next to IP addresses. |
|
| Country labels | Monospaced text + flag emoji or icon | Geo data next to IP addresses. |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bangui-backend"
|
name = "bangui-backend"
|
||||||
version = "0.9.14"
|
version = "0.9.15"
|
||||||
description = "BanGUI backend — fail2ban web management interface"
|
description = "BanGUI backend — fail2ban web management interface"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
190
frontend/package-lock.json
generated
190
frontend/package-lock.json
generated
@@ -1,30 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"version": "0.9.14",
|
"version": "0.9.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"version": "0.9.14",
|
"version": "0.9.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-components": "^9.55.0",
|
"@fluentui/react-components": "^9.55.0",
|
||||||
"@fluentui/react-icons": "^2.0.257",
|
"@fluentui/react-icons": "^2.0.257",
|
||||||
"@types/react-simple-maps": "^3.0.6",
|
"d3-geo": "^3.1.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.13.0",
|
"@eslint/js": "^9.13.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.2",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/topojson-client": "^3.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||||
"@typescript-eslint/parser": "^8.13.0",
|
"@typescript-eslint/parser": "^8.13.0",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
@@ -3565,23 +3568,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-geo": {
|
"node_modules/@types/d3-geo": {
|
||||||
"version": "2.0.7",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||||
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
|
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/geojson": "*"
|
"@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": {
|
"node_modules/@types/d3-path": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
@@ -3597,12 +3592,6 @@
|
|||||||
"@types/d3-time": "*"
|
"@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": {
|
"node_modules/@types/d3-shape": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
@@ -3624,16 +3613,6 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -3652,6 +3631,7 @@
|
|||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
@@ -3696,16 +3676,25 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-simple-maps": {
|
"node_modules/@types/topojson-client": {
|
||||||
"version": "3.0.6",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||||
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
|
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-geo": "^2",
|
|
||||||
"@types/d3-zoom": "^2",
|
|
||||||
"@types/geojson": "*",
|
"@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": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
@@ -4476,28 +4465,6 @@
|
|||||||
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/d3-format": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
@@ -4508,12 +4475,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-geo": {
|
"node_modules/d3-geo": {
|
||||||
"version": "2.0.2",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
|
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-array": "^2.5.0"
|
"d3-array": "2.5.0 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-interpolate": {
|
"node_modules/d3-interpolate": {
|
||||||
@@ -4550,12 +4520,6 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-shape": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
@@ -4592,41 +4556,6 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@@ -5745,16 +5674,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
@@ -5982,18 +5901,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -6110,23 +6017,6 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/recharts": {
|
||||||
"version": "3.8.0",
|
"version": "3.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||||
@@ -7516,6 +7406,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.14",
|
"version": "0.9.16",
|
||||||
"description": "BanGUI frontend — fail2ban web management interface",
|
"description": "BanGUI frontend — fail2ban web management interface",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,14 +17,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-components": "^9.55.0",
|
"@fluentui/react-components": "^9.55.0",
|
||||||
"@fluentui/react-icons": "^2.0.257",
|
"@fluentui/react-icons": "^2.0.257",
|
||||||
"@types/react-simple-maps": "^3.0.6",
|
"d3-geo": "^3.1.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0",
|
"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"
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
|
"@types/topojson-client": "^3.0.0",
|
||||||
"@eslint/js": "^9.13.0",
|
"@eslint/js": "^9.13.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Divider,
|
Divider,
|
||||||
|
Input,
|
||||||
Text,
|
Text,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
@@ -35,6 +36,14 @@ export interface DashboardFilterBarProps {
|
|||||||
originFilter: BanOriginFilter;
|
originFilter: BanOriginFilter;
|
||||||
/** Called when the user selects a different origin filter. */
|
/** Called when the user selects a different origin filter. */
|
||||||
onOriginFilterChange: (value: BanOriginFilter) => void;
|
onOriginFilterChange: (value: BanOriginFilter) => void;
|
||||||
|
/** Jail filter value (optional). */
|
||||||
|
jail?: string;
|
||||||
|
/** Called when the jail filter text changes (optional). */
|
||||||
|
onJailChange?: (value: string) => void;
|
||||||
|
/** IP address filter value (optional). */
|
||||||
|
ip?: string;
|
||||||
|
/** Called when the IP address filter text changes (optional). */
|
||||||
|
onIpChange?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -92,6 +101,10 @@ export function DashboardFilterBar({
|
|||||||
onTimeRangeChange,
|
onTimeRangeChange,
|
||||||
originFilter,
|
originFilter,
|
||||||
onOriginFilterChange,
|
onOriginFilterChange,
|
||||||
|
jail,
|
||||||
|
onJailChange,
|
||||||
|
ip,
|
||||||
|
onIpChange,
|
||||||
}: DashboardFilterBarProps): React.JSX.Element {
|
}: DashboardFilterBarProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const cardStyles = useCardStyles();
|
const cardStyles = useCardStyles();
|
||||||
@@ -146,6 +159,48 @@ export function DashboardFilterBar({
|
|||||||
))}
|
))}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{onJailChange && (
|
||||||
|
<>
|
||||||
|
<div className={styles.divider}>
|
||||||
|
<Divider vertical />
|
||||||
|
</div>
|
||||||
|
<div className={styles.group}>
|
||||||
|
<Text weight="semibold" size={300}>
|
||||||
|
Jail
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. sshd"
|
||||||
|
size="small"
|
||||||
|
value={jail ?? ""}
|
||||||
|
onChange={(_ev, data): void => {
|
||||||
|
onJailChange(data.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onIpChange && (
|
||||||
|
<>
|
||||||
|
<div className={styles.divider}>
|
||||||
|
<Divider vertical />
|
||||||
|
</div>
|
||||||
|
<div className={styles.group}>
|
||||||
|
<Text weight="semibold" size={300}>
|
||||||
|
IP Address
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 192.168"
|
||||||
|
size="small"
|
||||||
|
value={ip ?? ""}
|
||||||
|
onChange={(_ev, data): void => {
|
||||||
|
onIpChange(data.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* WorldMap — SVG world map showing per-country ban counts.
|
* WorldMap — SVG world map showing per-country ban counts.
|
||||||
*
|
*
|
||||||
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from
|
* Uses a local TopoJSON bundle and d3-geo for projection, path generation,
|
||||||
* jsDelivr CDN. For each country that has bans in the selected time window,
|
* and native SVG pan/zoom behaviour.
|
||||||
* the total count is displayed inside the country's borders. Clicking a
|
|
||||||
* country filters the companion table.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useCallback, useState } from "react";
|
import {
|
||||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
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 { useCardStyles } from "../theme/commonStyles";
|
||||||
import type { GeoPermissibleObjects } from "d3-geo";
|
|
||||||
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
||||||
import { getBanCountColor } from "../utils/mapColors";
|
import { getBanCountColor } from "../utils/mapColors";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const MAP_WIDTH = 800;
|
||||||
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
const MAP_HEIGHT = 400;
|
||||||
// ---------------------------------------------------------------------------
|
const MIN_ZOOM = 1;
|
||||||
|
const MAX_ZOOM = 8;
|
||||||
const GEO_URL =
|
const ZOOM_STEP = 0.5;
|
||||||
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
const PAN_THRESHOLD = 3;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Styles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
mapWrapper: {
|
mapWrapper: {
|
||||||
@@ -33,6 +44,25 @@ const useStyles = makeStyles({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
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: {
|
countLabel: {
|
||||||
fontSize: "9px",
|
fontSize: "9px",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
@@ -73,195 +103,21 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
type TopoJsonTopology = Topology & {
|
||||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
objects: {
|
||||||
// ---------------------------------------------------------------------------
|
countries: TopoGeometryCollection;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface GeoLayerProps {
|
type TooltipState = {
|
||||||
countries: Record<string, number>;
|
cc: string;
|
||||||
countryNames?: Record<string, string>;
|
count: number;
|
||||||
selectedCountry: string | null;
|
name: string;
|
||||||
onSelectCountry: (cc: string | null) => void;
|
x: number;
|
||||||
thresholdLow: number;
|
y: number;
|
||||||
thresholdMedium: number;
|
} | null;
|
||||||
thresholdHigh: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GeoLayer({
|
interface WorldMapProps {
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 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
|
|
||||||
key={geo.rsmKey}
|
|
||||||
style={{ cursor: cc ? "pointer" : "default" }}
|
|
||||||
role={cc ? "button" : undefined}
|
|
||||||
tabIndex={cc ? 0 : undefined}
|
|
||||||
aria-label={cc
|
|
||||||
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
|
|
||||||
isSelected ? " (selected)" : ""
|
|
||||||
}`
|
|
||||||
: undefined}
|
|
||||||
aria-pressed={isSelected || undefined}
|
|
||||||
onClick={(): void => {
|
|
||||||
if (cc) handleClick(cc);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e): void => {
|
|
||||||
if (cc && (e.key === "Enter" || e.key === " ")) {
|
|
||||||
e.preventDefault();
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Geography
|
|
||||||
geography={geo}
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && (
|
|
||||||
<text
|
|
||||||
x={cx}
|
|
||||||
y={cy}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="central"
|
|
||||||
className={styles.countLabel}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tooltip &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
|
||||||
role="tooltip"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
|
||||||
<span className={styles.tooltipCount}>
|
|
||||||
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WorldMap — public component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface WorldMapProps {
|
|
||||||
/** ISO alpha-2 country code → ban count. */
|
/** ISO alpha-2 country code → ban count. */
|
||||||
countries: Record<string, number>;
|
countries: Record<string, number>;
|
||||||
/** Optional mapping from country code to display name. */
|
/** Optional mapping from country code to display name. */
|
||||||
@@ -289,21 +145,143 @@ export function WorldMap({
|
|||||||
}: WorldMapProps): React.JSX.Element {
|
}: WorldMapProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const cardStyles = useCardStyles();
|
const cardStyles = useCardStyles();
|
||||||
const [zoom, setZoom] = useState<number>(1);
|
const [zoom, setZoom] = useState<number>(MIN_ZOOM);
|
||||||
const [center, setCenter] = useState<[number, number]>([0, 0]);
|
const [center, setCenter] = useState<[number, number]>([0, 0]);
|
||||||
|
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
|
||||||
|
const [tooltip, setTooltip] = useState<TooltipState>(null);
|
||||||
|
|
||||||
const handleZoomIn = (): void => {
|
const zoomRef = useRef<number>(zoom);
|
||||||
setZoom((z) => Math.min(z + 0.5, 8));
|
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<boolean>(false);
|
||||||
|
|
||||||
const handleZoomOut = (): void => {
|
useEffect(() => {
|
||||||
setZoom((z) => Math.max(z - 0.5, 1));
|
zoomRef.current = zoom;
|
||||||
};
|
}, [zoom]);
|
||||||
|
|
||||||
const handleResetView = (): void => {
|
useEffect(() => {
|
||||||
setZoom(1);
|
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<unknown, Feature<Geometry, GeoJsonProperties>>>(
|
||||||
|
() => 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<SVGSVGElement>) => {
|
||||||
|
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<SVGSVGElement>) => {
|
||||||
|
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<SVGSVGElement>) => {
|
||||||
|
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<SVGSVGElement>) => {
|
||||||
|
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]);
|
setCenter([0, 0]);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -311,13 +289,12 @@ export function WorldMap({
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
||||||
>
|
>
|
||||||
{/* Zoom controls */}
|
|
||||||
<div className={styles.zoomControls}>
|
<div className={styles.zoomControls}>
|
||||||
<Button
|
<Button
|
||||||
appearance="secondary"
|
appearance="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleZoomIn}
|
onClick={handleZoomIn}
|
||||||
disabled={zoom >= 8}
|
disabled={zoom >= MAX_ZOOM}
|
||||||
title="Zoom in"
|
title="Zoom in"
|
||||||
aria-label="Zoom in"
|
aria-label="Zoom in"
|
||||||
>
|
>
|
||||||
@@ -327,7 +304,7 @@ export function WorldMap({
|
|||||||
appearance="secondary"
|
appearance="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
disabled={zoom <= 1}
|
disabled={zoom <= MIN_ZOOM}
|
||||||
title="Zoom out"
|
title="Zoom out"
|
||||||
aria-label="Zoom out"
|
aria-label="Zoom out"
|
||||||
>
|
>
|
||||||
@@ -337,7 +314,7 @@ export function WorldMap({
|
|||||||
appearance="secondary"
|
appearance="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleResetView}
|
onClick={handleResetView}
|
||||||
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
|
||||||
title="Reset view"
|
title="Reset view"
|
||||||
aria-label="Reset view"
|
aria-label="Reset view"
|
||||||
>
|
>
|
||||||
@@ -345,34 +322,126 @@ export function WorldMap({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComposableMap
|
<svg
|
||||||
projection="geoMercator"
|
className={styles.svg}
|
||||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
||||||
width={800}
|
role="img"
|
||||||
height={400}
|
aria-label="World map showing banned IP counts by country."
|
||||||
style={{ width: "100%", height: "auto" }}
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<ZoomableGroup
|
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
||||||
zoom={zoom}
|
{countryFeatures.map((featureItem) => {
|
||||||
center={center}
|
const rawId = featureItem.id;
|
||||||
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
|
const numericId = String(Number(rawId));
|
||||||
setZoom(newZoom);
|
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||||
setCenter(coordinates);
|
const count = cc !== null ? countries[cc] ?? 0 : 0;
|
||||||
}}
|
const isSelected = cc !== null && selectedCountry === cc;
|
||||||
minZoom={1}
|
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||||
maxZoom={8}
|
const pathString = pathGenerator(featureItem) ?? "";
|
||||||
>
|
if (!pathString) {
|
||||||
<GeoLayer
|
return null;
|
||||||
countries={countries}
|
}
|
||||||
countryNames={countryNames}
|
|
||||||
selectedCountry={selectedCountry}
|
const centroid = pathGenerator.centroid(featureItem);
|
||||||
onSelectCountry={onSelectCountry}
|
const [cx, cy] = centroid;
|
||||||
thresholdLow={thresholdLow}
|
const isCentroidValid = Number.isFinite(cx) && Number.isFinite(cy);
|
||||||
thresholdMedium={thresholdMedium}
|
|
||||||
thresholdHigh={thresholdHigh}
|
return (
|
||||||
/>
|
<g key={String(rawId)}>
|
||||||
</ZoomableGroup>
|
<path
|
||||||
</ComposableMap>
|
d={pathString}
|
||||||
|
role={cc ? "button" : undefined}
|
||||||
|
tabIndex={cc ? 0 : undefined}
|
||||||
|
aria-label={
|
||||||
|
cc
|
||||||
|
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
|
||||||
|
isSelected ? " (selected)" : ""
|
||||||
|
}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aria-pressed={isSelected || undefined}
|
||||||
|
className={`${styles.country} ${
|
||||||
|
isSelected ? styles.countrySelected : ""
|
||||||
|
} ${hoveredCountry === cc ? styles.countryHovered : ""}`}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
["--country-fill" as string]: fillColor,
|
||||||
|
["--country-hover-fill" as string]: isSelected
|
||||||
|
? tokens.colorBrandBackgroundHover
|
||||||
|
: tokens.colorBrandBackground2,
|
||||||
|
["--country-selected-fill" as string]: tokens.colorBrandBackground,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
onClick={(): void => {
|
||||||
|
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 && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
className={styles.countLabel}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{tooltip &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className={styles.tooltip}
|
||||||
|
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||||
|
role="tooltip"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
||||||
|
<span className={styles.tooltipCount}>
|
||||||
|
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,4 +125,47 @@ describe("DashboardFilterBar", () => {
|
|||||||
expect(onTimeRangeChange).toHaveBeenCalledOnce();
|
expect(onTimeRangeChange).toHaveBeenCalledOnce();
|
||||||
expect(onTimeRangeChange).toHaveBeenCalledWith("24h");
|
expect(onTimeRangeChange).toHaveBeenCalledWith("24h");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders jail and ip input controls when provided", async () => {
|
||||||
|
const onJailChange = vi.fn();
|
||||||
|
const onIpChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<DashboardFilterBar
|
||||||
|
timeRange="24h"
|
||||||
|
onTimeRangeChange={vi.fn()}
|
||||||
|
originFilter="all"
|
||||||
|
onOriginFilterChange={vi.fn()}
|
||||||
|
jail=""
|
||||||
|
onJailChange={onJailChange}
|
||||||
|
ip=""
|
||||||
|
onIpChange={onIpChange}
|
||||||
|
/>
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Jail/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/IP Address/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText(/e.g. sshd/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText(/e.g. 192.168/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const jailInput = screen.getByPlaceholderText(/e.g. sshd/i);
|
||||||
|
const ipInput = screen.getByPlaceholderText(/e.g. 192.168/i);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.clear(jailInput);
|
||||||
|
await user.type(jailInput, "x");
|
||||||
|
expect(onJailChange).toHaveBeenLastCalledWith("x");
|
||||||
|
|
||||||
|
await user.clear(ipInput);
|
||||||
|
await user.type(ipInput, "1");
|
||||||
|
expect(onIpChange).toHaveBeenLastCalledWith("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render jail or ip inputs when handlers are missing", () => {
|
||||||
|
renderBar();
|
||||||
|
expect(screen.queryByText(/Jail/i)).toBeNull();
|
||||||
|
expect(screen.queryByText(/IP Address/i)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
|
||||||
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
|
vi.mock(
|
||||||
vi.mock("react-simple-maps", () => ({
|
"world-atlas/countries-110m.json",
|
||||||
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
() => ({
|
||||||
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
default: {
|
||||||
Geography: ({ children }: { children?: React.ReactNode }) => <g>{children}</g>,
|
type: "Topology",
|
||||||
useGeographies: () => ({
|
objects: {
|
||||||
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
countries: {
|
||||||
path: { centroid: () => [10, 10] },
|
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";
|
import { WorldMap } from "../WorldMap";
|
||||||
|
|
||||||
@@ -34,16 +49,20 @@ describe("WorldMap", () => {
|
|||||||
</FluentProvider>,
|
</FluentProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tooltip should not be present initially
|
|
||||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
|
||||||
const countryButton = screen.getByRole("button", { name: /US: 42 bans/i });
|
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 });
|
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
|
||||||
|
|
||||||
const tooltip = screen.getByRole("tooltip");
|
const tooltip = screen.getByRole("tooltip");
|
||||||
expect(tooltip).toHaveTextContent("United States");
|
expect(tooltip).toHaveTextContent("United States");
|
||||||
expect(tooltip).toHaveTextContent("42 bans");
|
expect(tooltip).toHaveTextContent("42 bans");
|
||||||
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
|
|
||||||
|
|
||||||
fireEvent.mouseLeave(countryButton);
|
fireEvent.mouseLeave(countryButton);
|
||||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||||
"4": "AF",
|
"4": "AF",
|
||||||
"8": "AL",
|
"8": "AL",
|
||||||
|
"10": "AQ",
|
||||||
"12": "DZ",
|
"12": "DZ",
|
||||||
"16": "AS",
|
"16": "AS",
|
||||||
"20": "AD",
|
"20": "AD",
|
||||||
@@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
|||||||
"148": "TD",
|
"148": "TD",
|
||||||
"152": "CL",
|
"152": "CL",
|
||||||
"156": "CN",
|
"156": "CN",
|
||||||
|
"158": "TW",
|
||||||
"162": "CX",
|
"162": "CX",
|
||||||
"166": "CC",
|
"166": "CC",
|
||||||
"170": "CO",
|
"170": "CO",
|
||||||
@@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
|||||||
"250": "FR",
|
"250": "FR",
|
||||||
"254": "GF",
|
"254": "GF",
|
||||||
"258": "PF",
|
"258": "PF",
|
||||||
|
"260": "TF",
|
||||||
"262": "DJ",
|
"262": "DJ",
|
||||||
"266": "GA",
|
"266": "GA",
|
||||||
"268": "GE",
|
"268": "GE",
|
||||||
@@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
|||||||
"372": "IE",
|
"372": "IE",
|
||||||
"376": "IL",
|
"376": "IL",
|
||||||
"380": "IT",
|
"380": "IT",
|
||||||
|
"384": "CI",
|
||||||
"388": "JM",
|
"388": "JM",
|
||||||
"392": "JP",
|
"392": "JP",
|
||||||
"398": "KZ",
|
"398": "KZ",
|
||||||
|
|||||||
@@ -97,3 +97,21 @@ export function useMapData(
|
|||||||
refresh: load,
|
refresh: load,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test helper: returns arguments most recently used to call `useMapData`.
|
||||||
|
*
|
||||||
|
* This helper is only intended for test use with a mock implementation.
|
||||||
|
*/
|
||||||
|
export function getLastArgs(): { range: string; origin: string } {
|
||||||
|
throw new Error("getLastArgs is only available in tests with a mocked useMapData");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test helper: mutates mocked map data state.
|
||||||
|
*
|
||||||
|
* This helper is only intended for test use with a mock implementation.
|
||||||
|
*/
|
||||||
|
export function setMapData(_: Partial<UseMapDataResult>): void {
|
||||||
|
throw new Error("setMapData is only available in tests with a mocked useMapData");
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Rows with repeatedly-banned IPs are highlighted in amber.
|
* Rows with repeatedly-banned IPs are highlighted in amber.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
DataGridHeader,
|
DataGridHeader,
|
||||||
DataGridHeaderCell,
|
DataGridHeaderCell,
|
||||||
DataGridRow,
|
DataGridRow,
|
||||||
Input,
|
|
||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
Spinner,
|
Spinner,
|
||||||
@@ -82,11 +81,6 @@ const useStyles = makeStyles({
|
|||||||
gap: tokens.spacingHorizontalM,
|
gap: tokens.spacingHorizontalM,
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
},
|
},
|
||||||
filterLabel: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: tokens.spacingVerticalXS,
|
|
||||||
},
|
|
||||||
tableWrapper: {
|
tableWrapper: {
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
borderRadius: tokens.borderRadiusMedium,
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
@@ -136,6 +130,24 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utilities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function areHistoryQueriesEqual(
|
||||||
|
a: HistoryQuery,
|
||||||
|
b: HistoryQuery,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
a.range === b.range &&
|
||||||
|
a.origin === b.origin &&
|
||||||
|
a.jail === b.jail &&
|
||||||
|
a.ip === b.ip &&
|
||||||
|
a.page === b.page &&
|
||||||
|
a.page_size === b.page_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Column definitions for the main history table
|
// Column definitions for the main history table
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -388,15 +400,23 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
const { items, total, page, loading, error, setPage, refresh } =
|
const { items, total, page, loading, error, setPage, refresh } =
|
||||||
useHistory(appliedQuery);
|
useHistory(appliedQuery);
|
||||||
|
|
||||||
const applyFilters = useCallback((): void => {
|
useEffect((): void => {
|
||||||
setAppliedQuery({
|
const nextQuery: HistoryQuery = {
|
||||||
range: range,
|
range,
|
||||||
origin: originFilter !== "all" ? originFilter : undefined,
|
origin: originFilter !== "all" ? originFilter : undefined,
|
||||||
jail: jailFilter.trim() || undefined,
|
jail: jailFilter.trim() || undefined,
|
||||||
ip: ipFilter.trim() || undefined,
|
ip: ipFilter.trim() || undefined,
|
||||||
|
page: 1,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
});
|
};
|
||||||
}, [range, originFilter, jailFilter, ipFilter]);
|
|
||||||
|
if (areHistoryQueriesEqual(nextQuery, appliedQuery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(1);
|
||||||
|
setAppliedQuery(nextQuery);
|
||||||
|
}, [range, originFilter, jailFilter, ipFilter, setPage, appliedQuery]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
@@ -456,52 +476,15 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
onOriginFilterChange={(value) => {
|
onOriginFilterChange={(value) => {
|
||||||
setOriginFilter(value);
|
setOriginFilter(value);
|
||||||
}}
|
}}
|
||||||
/>
|
jail={jailFilter}
|
||||||
|
onJailChange={(value) => {
|
||||||
<div className={styles.filterLabel}>
|
setJailFilter(value);
|
||||||
<Text size={200}>Jail</Text>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. sshd"
|
|
||||||
value={jailFilter}
|
|
||||||
onChange={(_ev, data): void => {
|
|
||||||
setJailFilter(data.value);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.filterLabel}>
|
|
||||||
<Text size={200}>IP Address</Text>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. 192.168"
|
|
||||||
value={ipFilter}
|
|
||||||
onChange={(_ev, data): void => {
|
|
||||||
setIpFilter(data.value);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
onKeyDown={(e): void => {
|
|
||||||
if (e.key === "Enter") applyFilters();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button appearance="primary" size="small" onClick={applyFilters}>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
onClick={(): void => {
|
|
||||||
setRange("24h");
|
|
||||||
setOriginFilter("all");
|
|
||||||
setJailFilter("");
|
|
||||||
setIpFilter("");
|
|
||||||
setAppliedQuery({ page_size: PAGE_SIZE });
|
|
||||||
}}
|
}}
|
||||||
>
|
ip={ipFilter}
|
||||||
Clear
|
onIpChange={(value) => {
|
||||||
</Button>
|
setIpFilter(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ import {
|
|||||||
makeStyles,
|
makeStyles,
|
||||||
tokens,
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
import {
|
||||||
|
ArrowCounterclockwiseRegular,
|
||||||
|
ChevronLeftRegular,
|
||||||
|
ChevronRightRegular,
|
||||||
|
DismissRegular,
|
||||||
|
} from "@fluentui/react-icons";
|
||||||
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||||
import { WorldMap } from "../components/WorldMap";
|
import { WorldMap } from "../components/WorldMap";
|
||||||
import { useMapData } from "../hooks/useMapData";
|
import { useMapData } from "../hooks/useMapData";
|
||||||
@@ -68,6 +73,15 @@ const useStyles = makeStyles({
|
|||||||
borderRadius: tokens.borderRadiusMedium,
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
},
|
},
|
||||||
|
pagination: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||||
|
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -79,6 +93,10 @@ export function MapPage(): React.JSX.Element {
|
|||||||
const [range, setRange] = useState<TimeRange>("24h");
|
const [range, setRange] = useState<TimeRange>("24h");
|
||||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState<number>(1);
|
||||||
|
const [pageSize, setPageSize] = useState<number>(100);
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [25, 50, 100] as const;
|
||||||
|
|
||||||
const { countries, countryNames, bans, total, loading, error, refresh } =
|
const { countries, countryNames, bans, total, loading, error, refresh } =
|
||||||
useMapData(range, originFilter);
|
useMapData(range, originFilter);
|
||||||
@@ -99,6 +117,10 @@ export function MapPage(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [mapThresholdError]);
|
}, [mapThresholdError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [range, originFilter, selectedCountry, bans, pageSize]);
|
||||||
|
|
||||||
/** Bans visible in the companion table (filtered by selected country). */
|
/** Bans visible in the companion table (filtered by selected country). */
|
||||||
const visibleBans = useMemo(() => {
|
const visibleBans = useMemo(() => {
|
||||||
if (!selectedCountry) return bans;
|
if (!selectedCountry) return bans;
|
||||||
@@ -109,6 +131,15 @@ export function MapPage(): React.JSX.Element {
|
|||||||
? (countryNames[selectedCountry] ?? selectedCountry)
|
? (countryNames[selectedCountry] ?? selectedCountry)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(visibleBans.length / pageSize));
|
||||||
|
const hasPrev = page > 1;
|
||||||
|
const hasNext = page < totalPages;
|
||||||
|
|
||||||
|
const pageBans = useMemo(() => {
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return visibleBans.slice(start, start + pageSize);
|
||||||
|
}, [visibleBans, page, pageSize]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
@@ -235,7 +266,7 @@ export function MapPage(): React.JSX.Element {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
visibleBans.map((ban) => (
|
pageBans.map((ban) => (
|
||||||
<TableRow key={`${ban.ip}-${ban.banned_at}`}>
|
<TableRow key={`${ban.ip}-${ban.banned_at}`}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TableCellLayout>{ban.ip}</TableCellLayout>
|
<TableCellLayout>{ban.ip}</TableCellLayout>
|
||||||
@@ -282,6 +313,53 @@ export function MapPage(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
Showing {pageBans.length} of {visibleBans.length} filtered ban{visibleBans.length !== 1 ? "s" : ""}
|
||||||
|
{" · "}Page {page} of {totalPages}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
Page size
|
||||||
|
</Text>
|
||||||
|
<select
|
||||||
|
aria-label="Page size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event): void => {
|
||||||
|
setPageSize(Number(event.target.value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: tokens.spacingHorizontalXS }}>
|
||||||
|
<Button
|
||||||
|
icon={<ChevronLeftRegular />}
|
||||||
|
appearance="subtle"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={(): void => {
|
||||||
|
setPage(page - 1);
|
||||||
|
}}
|
||||||
|
aria-label="Previous page"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ChevronRightRegular />}
|
||||||
|
appearance="subtle"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={(): void => {
|
||||||
|
setPage(page + 1);
|
||||||
|
}}
|
||||||
|
aria-label="Next page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
import { HistoryPage } from "../HistoryPage";
|
|
||||||
|
|
||||||
let lastQuery: Record<string, unknown> | null = null;
|
let lastQuery: Record<string, unknown> | null = null;
|
||||||
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
|
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
|
||||||
|
console.log("mockUseHistory called", query);
|
||||||
lastQuery = query;
|
lastQuery = query;
|
||||||
return {
|
return {
|
||||||
items: [],
|
items: [],
|
||||||
@@ -18,16 +18,16 @@ const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../hooks/useHistory", () => ({
|
vi.mock("../../hooks/useHistory", () => ({
|
||||||
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
|
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
|
||||||
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
|
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../components/WorldMap", () => ({
|
vi.mock("../../components/WorldMap", () => ({
|
||||||
WorldMap: () => <div data-testid="world-map" />,
|
WorldMap: () => <div data-testid="world-map" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/config", () => ({
|
vi.mock("../../api/config", () => ({
|
||||||
fetchMapColorThresholds: async () => ({
|
fetchMapColorThresholds: async () => ({
|
||||||
threshold_low: 10,
|
threshold_low: 10,
|
||||||
threshold_medium: 50,
|
threshold_medium: 50,
|
||||||
@@ -35,8 +35,10 @@ vi.mock("../api/config", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { HistoryPage } from "../HistoryPage";
|
||||||
|
|
||||||
describe("HistoryPage", () => {
|
describe("HistoryPage", () => {
|
||||||
it("renders DashboardFilterBar and applies origin+range filters", async () => {
|
it("auto-applies filters on change and hides apply/clear actions", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -45,14 +47,30 @@ describe("HistoryPage", () => {
|
|||||||
</FluentProvider>,
|
</FluentProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial load should include the default query.
|
// Initial load should include the auto-applied default query.
|
||||||
expect(lastQuery).toEqual({ page_size: 50 });
|
await waitFor(() => {
|
||||||
|
expect(lastQuery).toEqual({
|
||||||
|
range: "24h",
|
||||||
|
origin: undefined,
|
||||||
|
jail: undefined,
|
||||||
|
ip: undefined,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Change the time-range and origin filter, then apply.
|
expect(screen.queryByRole("button", { name: /apply/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /clear/i })).toBeNull();
|
||||||
|
|
||||||
|
// Time-range and origin updates should be applied automatically.
|
||||||
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||||
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
await waitFor(() => {
|
||||||
await user.click(screen.getByRole("button", { name: /Apply/i }));
|
expect(lastQuery).toMatchObject({ range: "7d" });
|
||||||
|
});
|
||||||
|
|
||||||
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" });
|
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastQuery).toMatchObject({ origin: "blocklist" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,42 +2,43 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
import { getLastArgs, setMapData } from "../../hooks/useMapData";
|
||||||
import { MapPage } from "../MapPage";
|
import { MapPage } from "../MapPage";
|
||||||
|
|
||||||
const mockFetchMapColorThresholds = vi.fn(async () => ({
|
vi.mock("../../hooks/useMapData", () => {
|
||||||
threshold_low: 10,
|
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
|
||||||
threshold_medium: 50,
|
let dataState = {
|
||||||
threshold_high: 100,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
|
|
||||||
const mockUseMapData = vi.fn((range: string, origin: string) => {
|
|
||||||
lastArgs = { range, origin };
|
|
||||||
return {
|
|
||||||
countries: {},
|
countries: {},
|
||||||
countryNames: {},
|
countryNames: {},
|
||||||
bans: [],
|
bans: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
refresh: vi.fn(),
|
refresh: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
useMapData: (range: string, origin: string) => {
|
||||||
|
lastArgs = { range, origin };
|
||||||
|
return { ...dataState };
|
||||||
|
},
|
||||||
|
setMapData: (newState: Partial<typeof dataState>) => {
|
||||||
|
dataState = { ...dataState, ...newState };
|
||||||
|
},
|
||||||
|
getLastArgs: () => lastArgs,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../hooks/useMapData", () => ({
|
vi.mock("../../api/config", () => ({
|
||||||
useMapData: (range: string, origin: string) => mockUseMapData(range, origin),
|
fetchMapColorThresholds: vi.fn(async () => ({
|
||||||
|
threshold_low: 10,
|
||||||
|
threshold_medium: 50,
|
||||||
|
threshold_high: 100,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/config", async () => ({
|
vi.mock("../../components/WorldMap", () => ({
|
||||||
fetchMapColorThresholds: mockFetchMapColorThresholds,
|
WorldMap: () => <div data-testid="world-map" />,
|
||||||
}));
|
|
||||||
|
|
||||||
const mockWorldMap = vi.fn((_props: unknown) => <div data-testid="world-map" />);
|
|
||||||
vi.mock("../components/WorldMap", () => ({
|
|
||||||
WorldMap: (props: unknown) => {
|
|
||||||
mockWorldMap(props);
|
|
||||||
return <div data-testid="world-map" />;
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("MapPage", () => {
|
describe("MapPage", () => {
|
||||||
@@ -51,17 +52,63 @@ describe("MapPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Initial load should call useMapData with default filters.
|
// Initial load should call useMapData with default filters.
|
||||||
expect(lastArgs).toEqual({ range: "24h", origin: "all" });
|
expect(getLastArgs()).toEqual({ range: "24h", origin: "all" });
|
||||||
|
|
||||||
// Map should receive country names from the hook so tooltips can show human-readable labels.
|
|
||||||
expect(mockWorldMap).toHaveBeenCalled();
|
|
||||||
const firstCallArgs = mockWorldMap.mock.calls[0]?.[0];
|
|
||||||
expect(firstCallArgs).toMatchObject({ countryNames: {} });
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||||
expect(lastArgs.range).toBe("7d");
|
expect(getLastArgs().range).toBe("7d");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||||
expect(lastArgs.origin).toBe("blocklist");
|
expect(getLastArgs().origin).toBe("blocklist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports pagination with 100 items per page and reset on filter changes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const bans: import("../../types/map").MapBanItem[] = Array.from({ length: 120 }, (_, index) => ({
|
||||||
|
ip: `192.0.2.${index}`,
|
||||||
|
jail: "ssh",
|
||||||
|
banned_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||||
|
service: null,
|
||||||
|
country_code: "US",
|
||||||
|
country_name: "United States",
|
||||||
|
asn: null,
|
||||||
|
org: null,
|
||||||
|
ban_count: 1,
|
||||||
|
origin: "selfblock",
|
||||||
|
}));
|
||||||
|
|
||||||
|
setMapData({
|
||||||
|
countries: { US: 120 },
|
||||||
|
countryNames: { US: "United States" },
|
||||||
|
bans,
|
||||||
|
total: 120,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<MapPage />
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Page 1 of 2/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Showing 100 of 120 filtered bans/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /Next page/i }));
|
||||||
|
expect(await screen.findByText(/Page 2 of 2/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /Previous page/i }));
|
||||||
|
expect(await screen.findByText(/Page 1 of 2/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Page size selector should adjust pagination
|
||||||
|
await user.selectOptions(screen.getByRole("combobox", { name: /Page size/i }), "25");
|
||||||
|
expect(await screen.findByText(/Page 1 of 5/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Showing 25 of 120 filtered bans/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Changing filter keeps page reset to 1
|
||||||
|
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||||
|
expect(getLastArgs().origin).toBe("blocklist");
|
||||||
|
expect(await screen.findByText(/Page 1 of 5/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
Reference in New Issue
Block a user