Add dashboard country charts (Stages 1–3)
- Install Recharts v3 as the project charting library - Add chartTheme utility with Fluent UI v9 token resolution helper and a 5-colour categorical palette (resolves CSS vars at runtime) - Add TopCountriesPieChart: top-4 + Other slice, Tooltip, Legend - Add TopCountriesBarChart: horizontal top-20 bar chart - Add useDashboardCountryData hook (wraps /api/dashboard/bans/by-country) - Integrate both charts into DashboardPage in a responsive chartsRow (side-by-side on wide screens, stacked on narrow) - All tsc --noEmit and eslint checks pass with zero warnings
This commit is contained in:
583
Docs/Tasks.md
583
Docs/Tasks.md
@@ -4,224 +4,479 @@ This document breaks the entire BanGUI project into development stages, ordered
|
||||
|
||||
---
|
||||
|
||||
## Issue: World Map Loading Time — Architecture Fix
|
||||
## Stage 1 — Dashboard Charts Foundation
|
||||
|
||||
### Problem Summary
|
||||
### Task 1.1 — Install and configure a charting library
|
||||
|
||||
The `GET /api/dashboard/bans/by-country` endpoint is extremely slow on first load. A single request with ~5,200 unique IPs produces **10,400 SQLite commits** and **6,000 INSERT statements** against the app database — all during a read-only GET request. The log shows 21,000+ lines of SQL trace for just 18 HTTP requests.
|
||||
**Status:** `done`
|
||||
|
||||
Root causes (ordered by impact):
|
||||
The frontend currently has no charting library. Install **Recharts** (`recharts`) as the project charting library. Recharts is React-native, composable, and integrates cleanly with Fluent UI v9 theming.
|
||||
|
||||
1. **Per-IP commit during geo cache writes** — `geo_service._persist_entry()` and `_persist_neg_entry()` each call `await db.commit()` after every single INSERT. With 5,200 uncached IPs this means 5,200+ individual commits, each forcing an `fsync`. This is the dominant bottleneck.
|
||||
2. **DB writes on a GET request** — The bans/by-country endpoint passes `app_db` to `geo_service.lookup_batch()`, which triggers INSERT+COMMIT for every resolved IP. A GET request should never produce database writes/commits. Users do not expect loading a map page to mutate the database.
|
||||
3. **Same pattern exists in other endpoints** — The following GET endpoints also trigger geo cache commits: `/api/dashboard/bans`, `/api/bans/active`, `/api/history`, `/api/history/{ip}`, `/api/geo/lookup/{ip}`.
|
||||
**Steps:**
|
||||
|
||||
### Evidence from `log.log`
|
||||
1. Run `npm install recharts` in the `frontend/` directory.
|
||||
2. Verify the dependency appears in `package.json` under `dependencies`.
|
||||
3. Confirm the build still succeeds with `npm run build` (no type errors, no warnings).
|
||||
|
||||
- Log line count: **21,117 lines** for 18 HTTP requests
|
||||
- `INSERT INTO geo_cache`: **6,000** executions
|
||||
- `db.commit()`: **10,400** calls (each INSERT + its commit = 2 ops per IP)
|
||||
- `geo_batch_lookup_start`: reports `total=5200` uncached IPs
|
||||
- The bans/by-country response is at line **21,086** out of 21,117 — the entire log is essentially one request's geo persist work
|
||||
- Other requests (`/api/dashboard/status`, `/api/blocklists/schedule`, `/api/config/map-color-thresholds`) interleave with the geo persist loop because they share the same single async DB connection
|
||||
No wrapper or configuration file is needed — Recharts components are imported directly where used.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- `recharts` is listed in `frontend/package.json`.
|
||||
- `npm run build` succeeds with zero errors or warnings.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Batch geo cache writes — eliminate per-IP commits ✅ DONE
|
||||
### Task 1.2 — Create a shared chart theme utility
|
||||
|
||||
**File:** `backend/app/services/geo_service.py`
|
||||
**Status:** `done`
|
||||
|
||||
**What to change:**
|
||||
Create a small utility at `frontend/src/utils/chartTheme.ts` that exports a function (or constant object) mapping Fluent UI v9 design tokens to Recharts-compatible colour values. The charts must respect the current Fluent theme (light and dark mode). At minimum export:
|
||||
|
||||
The functions `_persist_entry()` and `_persist_neg_entry()` each call `await db.commit()` after every INSERT. Instead, the commit should happen once after the entire batch is processed.
|
||||
- A palette of 5+ distinct categorical colours for pie/bar slices, derived from Fluent token aliases (e.g. `colorPaletteBlueBorderActive`, `colorPaletteRedBorderActive`, etc.).
|
||||
- Axis/grid/tooltip colours derived from `colorNeutralForeground2`, `colorNeutralStroke2`, `colorNeutralBackground1`, etc.
|
||||
- A helper that returns the CSS value of a Fluent token at runtime (since Recharts needs literal CSS colour strings, not CSS custom properties).
|
||||
|
||||
1. Remove `await db.commit()` from both `_persist_entry()` and `_persist_neg_entry()`.
|
||||
2. In `lookup_batch()`, after the loop over all chunks is complete and all `_persist_entry()` / `_persist_neg_entry()` calls have been made, issue a single `await db.commit()` if `db is not None`.
|
||||
3. Wrap the single commit in a try/except to handle any errors gracefully.
|
||||
Keep the file under 60 lines. No React components here — pure utility.
|
||||
|
||||
**Expected impact:** Reduces commits from ~5,200 to **1** per request. This alone should cut the endpoint response time dramatically.
|
||||
**References:** [Web-Design.md](Web-Design.md) § colour tokens.
|
||||
|
||||
**Testing:** Existing tests in `test_services/test_ban_service.py` and `test_services/test_geo_service.py` should continue to pass. Verify the geo_cache table still gets populated after a batch lookup by checking the DB contents in an integration test.
|
||||
**Acceptance criteria:**
|
||||
|
||||
- The exported palette contains at least 5 distinct colours.
|
||||
- Colours change correctly between light and dark mode.
|
||||
- `tsc --noEmit` and `eslint` pass with zero warnings.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Do not write geo cache during GET requests ✅ DONE
|
||||
## Stage 2 — Country Pie Chart (Top 4 + Other)
|
||||
|
||||
**Files:** `backend/app/routers/dashboard.py`, `backend/app/routers/bans.py`, `backend/app/routers/history.py`, `backend/app/routers/geo.py`
|
||||
### Task 2.1 — Create the `TopCountriesPieChart` component
|
||||
|
||||
**What to change:**
|
||||
**Status:** `done`
|
||||
|
||||
GET endpoints should not pass `app_db` (or equivalent) to geo_service functions. The geo resolution should still populate the in-memory cache (which is fast, free, and ephemeral), but should NOT write to SQLite during a read request.
|
||||
Create `frontend/src/components/TopCountriesPieChart.tsx`. This component renders a **pie chart (Kuchendiagramm)** showing the **top 4 countries by ban count** plus an **"Other"** slice that aggregates every remaining country.
|
||||
|
||||
For each of these GET endpoints:
|
||||
- `GET /api/dashboard/bans/by-country` in `dashboard.py` — stop passing `app_db=db` to `bans_by_country()`; pass `app_db=None` instead.
|
||||
- `GET /api/dashboard/bans` in `dashboard.py` — stop passing `app_db=db` to `list_bans()`; pass `app_db=None` instead.
|
||||
- `GET /api/bans/active` in `bans.py` — the enricher callback should not pass `db` to `geo_service.lookup()`.
|
||||
- `GET /api/history` and `GET /api/history/{ip}` in `history.py` — same: enricher should not pass `db`.
|
||||
- `GET /api/geo/lookup/{ip}` in `geo.py` — do not pass `db` to `geo_service.lookup()`.
|
||||
**Data source:** The component receives the `countries` map (`Record<string, number>`) and `country_names` map (`Record<string, string>`) from the existing `/api/dashboard/bans/by-country` endpoint response (`BansByCountryResponse`). No new API endpoint is needed.
|
||||
|
||||
The persistent geo cache should only be written during explicit write operations:
|
||||
- `POST /api/geo/re-resolve` (already a POST — this is correct)
|
||||
- Blocklist import tasks (`blocklist_service.py`)
|
||||
- Application startup via `load_cache_from_db()`
|
||||
**Aggregation logic (frontend):**
|
||||
|
||||
**Expected impact:** GET requests become truly read-only. No commits, no `fsync`, no write contention on the app DB during map loads.
|
||||
1. Sort the `countries` entries descending by ban count.
|
||||
2. Take the top 4 entries.
|
||||
3. Sum all remaining entries into a single `"Other"` bucket.
|
||||
4. The result is exactly 5 slices (or fewer if fewer than 5 countries exist).
|
||||
|
||||
**Testing:** Run the full test suite. Verify that:
|
||||
1. The bans/by-country endpoint still returns correct country data (from in-memory cache).
|
||||
2. The `geo_cache` table is still populated when `POST /api/geo/re-resolve` is called or after blocklist import.
|
||||
3. After a server restart, geo data is still available (because `load_cache_from_db()` warms memory from previously persisted data).
|
||||
**Visual requirements:**
|
||||
|
||||
---
|
||||
- Use `<PieChart>` and `<Pie>` from Recharts with `<Cell>` for per-slice colours from the chart theme palette (Task 1.2).
|
||||
- Display a `<Tooltip>` on hover showing the country name and ban count.
|
||||
- Display a `<Legend>` listing each slice with its country name (full name from `country_names`, not just the code) and percentage.
|
||||
- Label each slice with the percentage (use Recharts `label` prop or `<Label>`).
|
||||
- Use `makeStyles` for any layout styling. Follow [Web-Design.md](Web-Design.md) spacing and card conventions.
|
||||
- Wrap the chart in a responsive container so it scales with its parent.
|
||||
|
||||
### Task 3: Periodically persist the in-memory geo cache (background task) ✅ DONE
|
||||
**Props interface:**
|
||||
|
||||
**Files:** `backend/app/services/geo_service.py`, `backend/app/tasks/` (new task file)
|
||||
|
||||
**What to change:**
|
||||
|
||||
After Task 2, GET requests no longer write to the DB. But newly resolved IPs during GET requests only live in the in-memory cache and would be lost on restart. To avoid this, add a background task that periodically flushes new in-memory entries to the `geo_cache` table.
|
||||
|
||||
1. In `geo_service.py`, add a module-level `_dirty: set[str]` that tracks IPs added to `_cache` but not yet persisted. When `_store()` adds an entry, also add the IP to `_dirty`.
|
||||
2. Add a new function `flush_dirty(db: aiosqlite.Connection) -> int` that:
|
||||
- Takes the current `_dirty` set and clears it atomically.
|
||||
- Uses `executemany()` to batch-INSERT all dirty entries in one transaction.
|
||||
- Calls `db.commit()` once.
|
||||
- Returns the count of flushed entries.
|
||||
3. Register a periodic task (using the existing APScheduler setup) that calls `flush_dirty()` every 60 seconds (configurable). This is similar to how other background tasks already run.
|
||||
|
||||
**Expected impact:** Geo data is persisted without blocking any request. If the server restarts, at most 60 seconds of new geo data is lost (and it will simply be re-fetched from ip-api.com on the next request).
|
||||
|
||||
**Testing:** Write a test that:
|
||||
- Calls `lookup_batch()` without a DB reference.
|
||||
- Verifies IPs are in `_dirty`.
|
||||
- Calls `flush_dirty(db)`.
|
||||
- Verifies the geo_cache table contains the entries and `_dirty` is empty.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Reduce redundant SQL queries per request (settings / auth) ✅ DONE
|
||||
|
||||
**Files:** `backend/app/dependencies.py`, `backend/app/main.py`, `backend/app/repositories/settings_repo.py`
|
||||
|
||||
**What to change:**
|
||||
|
||||
The log shows that every single HTTP request executes at least 2 separate SQL queries before reaching the actual endpoint logic:
|
||||
1. `SELECT value FROM settings WHERE key = 'setup_completed'` (SetupRedirectMiddleware)
|
||||
2. `SELECT id, token, ... FROM sessions WHERE token = ?` (require_auth dependency)
|
||||
|
||||
When multiple requests arrive concurrently (as seen in the log — 3 parallel requests trigger 3× setup_completed + 3× session token queries), this adds unnecessary DB contention.
|
||||
|
||||
Options (implement one or both):
|
||||
- **Cache `setup_completed` in memory:** Once setup is complete, it never goes back to incomplete. Cache the result in `app.state` and skip the DB query on subsequent requests. Set it on first `True` read and clear it only if the app restarts.
|
||||
- **Cache session validation briefly:** Use a short TTL in-memory cache (e.g., 5–10 seconds) for validated session tokens. This reduces repeated DB lookups for the same token across near-simultaneous requests.
|
||||
|
||||
**Expected impact:** Reduces per-request overhead from 2+ SQL queries to 0 for the common case (setup done, session recently validated).
|
||||
|
||||
**Testing:** Existing auth and setup tests must continue to pass. Add a test that validates the cached path (second request skips DB).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Audit and verify — run full test suite ✅ DONE
|
||||
|
||||
After tasks 1–4 are implemented, run:
|
||||
|
||||
```bash
|
||||
cd backend && python -m pytest tests/ -x -q
|
||||
```ts
|
||||
interface TopCountriesPieChartProps {
|
||||
countries: Record<string, number>;
|
||||
countryNames: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
Verify:
|
||||
- All tests pass (460 passing, up from 443 baseline).
|
||||
- `ruff check backend/app/` passes.
|
||||
- `mypy --strict` passes on all changed files.
|
||||
- 83% overall coverage (above the 80% threshold).
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Always renders exactly 5 slices (or fewer when data has < 5 countries).
|
||||
- The "Other" slice correctly sums all countries outside the top 4.
|
||||
- Tooltip displays country name + count on hover.
|
||||
- Legend shows country name + percentage.
|
||||
- Responsive — no horizontal overflow on narrow viewports.
|
||||
- `tsc --noEmit` passes. No `any` types. ESLint clean.
|
||||
|
||||
---
|
||||
|
||||
## Developer Notes — Learnings & Gotchas
|
||||
### Task 2.2 — Create a `useDashboardCountryData` hook
|
||||
|
||||
These notes capture non-obvious findings from the investigation. Read them before you start coding.
|
||||
**Status:** `done`
|
||||
|
||||
### Architecture Overview
|
||||
Create `frontend/src/hooks/useDashboardCountryData.ts`. This hook wraps the existing `GET /api/dashboard/bans/by-country` call and returns the data the dashboard charts need. The existing `useMapData` hook is designed for the map page and should not be reused because it is coupled to map-specific debouncing and state.
|
||||
|
||||
BanGUI has **two separate SQLite databases**:
|
||||
**Signature:**
|
||||
|
||||
1. **fail2ban DB** — owned by fail2ban, opened read-only (`?mode=ro`) via `aiosqlite.connect(f"file:{path}?mode=ro", uri=True)`. Path is discovered at runtime by asking the fail2ban daemon (`get dbfile` via Unix socket). Contains the `bans` table.
|
||||
2. **App DB** (`bangui.db`) — BanGUI's own database. Holds `settings`, `sessions`, `geo_cache`, `blocklist_sources`, `import_log`. This is the one being hammered by commits during GET requests.
|
||||
```ts
|
||||
function useDashboardCountryData(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter,
|
||||
): {
|
||||
countries: Record<string, number>;
|
||||
countryNames: Record<string, string>;
|
||||
bans: DashboardBanItem[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
There is a **single shared app DB connection** living at `request.app.state.db`. All concurrent requests share it. This means long-running writes (like 5,200 sequential INSERT+COMMIT loops) block other requests that need the same connection. The log confirms this: `setup_completed` checks and session lookups from parallel requests interleave with the geo persist loop.
|
||||
**Behaviour:**
|
||||
|
||||
### The Geo Resolution Pipeline
|
||||
- Call `GET /api/dashboard/bans/by-country?range={timeRange}` with optional `origin` query param (omit when `"all"`).
|
||||
- Use the typed API client from `api/client.ts`.
|
||||
- Set `isLoading` while fetching, populate `error` on failure.
|
||||
- Re-fetch when `timeRange` or `origin` changes.
|
||||
- Mirror the data-fetching patterns used by `useBans` / `useMapData`.
|
||||
|
||||
`geo_service.py` implements a two-tier cache:
|
||||
**Acceptance criteria:**
|
||||
|
||||
1. **In-memory dict** (`_cache: dict[str, GeoInfo]`) — module-level, lives for the process lifetime. Fast, no I/O.
|
||||
2. **SQLite `geo_cache` table** — survives restarts. Loaded into `_cache` at startup via `load_cache_from_db()`.
|
||||
- Returns typed data matching `BansByCountryResponse`.
|
||||
- Re-fetches on param change.
|
||||
- `tsc --noEmit` and ESLint pass.
|
||||
|
||||
There is also a **negative cache** (`_neg_cache: dict[str, float]`) for failed lookups with a 5-minute TTL. Failed IPs are not retried within that window.
|
||||
---
|
||||
|
||||
The batch resolution flow in `lookup_batch()`:
|
||||
1. Check `_cache` and `_neg_cache` for each IP → split into cached vs uncached.
|
||||
2. Send uncached IPs to `ip-api.com/batch` in chunks of 100.
|
||||
3. For each resolved IP: update `_cache` (fast) AND call `_persist_entry(db, ip, info)` (slow — INSERT + COMMIT).
|
||||
4. For failed IPs: try MaxMind GeoLite2 local DB fallback (`_geoip_lookup()`). If that also fails, add to `_neg_cache` and call `_persist_neg_entry()`.
|
||||
### Task 2.3 — Integrate the pie chart into `DashboardPage`
|
||||
|
||||
**Critical insight:** Step 3 is where the bottleneck lives. The `_persist_entry` function issues a separate `await db.commit()` after each INSERT. With 5,200 IPs, that's 5,200 `fsync` calls — each one waits for the disk.
|
||||
**Status:** `done`
|
||||
|
||||
### Specific File Locations You Need
|
||||
Add the `TopCountriesPieChart` below the `ServerStatusBar` and above the "Ban List" section on the `DashboardPage`. The chart must share the same `timeRange` and `originFilter` state that already exists on the page.
|
||||
|
||||
| File | Key functions | Notes |
|
||||
|------|--------------|-------|
|
||||
| `backend/app/services/geo_service.py` L231–260 | `_persist_entry()` | The INSERT + COMMIT per IP — **this is the hot path** |
|
||||
| `backend/app/services/geo_service.py` L262–280 | `_persist_neg_entry()` | Same pattern for failed lookups |
|
||||
| `backend/app/services/geo_service.py` L374–460 | `lookup_batch()` | Main batch function — calls `_persist_entry` in a loop |
|
||||
| `backend/app/services/geo_service.py` L130–145 | `_store()` | Updates the in-memory `_cache` dict — fast, no I/O |
|
||||
| `backend/app/services/geo_service.py` L202–230 | `load_cache_from_db()` | Startup warm-up, reads entire `geo_cache` table into memory |
|
||||
| `backend/app/services/ban_service.py` L326–430 | `bans_by_country()` | Calls `lookup_batch()` with `db=app_db` |
|
||||
| `backend/app/services/ban_service.py` L130–210 | `list_bans()` | Also calls `lookup_batch()` with `app_db` |
|
||||
| `backend/app/routers/dashboard.py` | `get_bans_by_country()` | Passes `app_db=db` — this is where db gets threaded through |
|
||||
| `backend/app/routers/bans.py` | `get_active_bans()` | Uses single-IP `lookup()` via enricher callback with `db` |
|
||||
| `backend/app/routers/history.py` | `get_history()`, `get_ip_history()` | Same enricher-with-db pattern |
|
||||
| `backend/app/routers/geo.py` | `lookup_ip()` | Single IP lookup, passes `db` |
|
||||
| `backend/app/main.py` L268–306 | `SetupRedirectMiddleware` | Runs `get_setting(db, "setup_completed")` on every request |
|
||||
| `backend/app/dependencies.py` L54–100 | `require_auth()` | Runs session token SELECT on every authenticated request |
|
||||
| `backend/app/repositories/settings_repo.py` | `get_setting()` | Individual SELECT per key; `get_all_settings()` exists but is unused in middleware |
|
||||
**Layout:**
|
||||
|
||||
### Endpoints That Commit During GET Requests
|
||||
- Place the pie chart inside a new section card (reuse the `section` / `sectionHeader` pattern from the existing ban-list section).
|
||||
- Section title: **"Top Countries"**.
|
||||
- The pie chart card sits in a future row of chart cards (see Task 3.3). For now, render it full-width. Use a CSS class name like `chartsRow` so the bar chart can be added beside it later.
|
||||
|
||||
All of these GET endpoints currently write to the app DB via geo_service:
|
||||
**Acceptance criteria:**
|
||||
|
||||
| Endpoint | How | Commit count per request |
|
||||
|----------|-----|--------------------------|
|
||||
| `GET /api/dashboard/bans/by-country` | `bans_by_country()` → `lookup_batch()` → `_persist_entry()` per IP | Up to N (N = uncached IPs, can be thousands) |
|
||||
| `GET /api/dashboard/bans` | `list_bans()` → `lookup_batch()` → `_persist_entry()` per IP | Up to page_size (max 500) |
|
||||
| `GET /api/bans/active` | enricher → `lookup()` → `_persist_entry()` per IP | 1 per ban in response |
|
||||
| `GET /api/history` | enricher → `lookup()` → `_persist_entry()` per IP | 1 per row |
|
||||
| `GET /api/history/{ip}` | enricher → `lookup()` → `_persist_entry()` | 1 |
|
||||
| `GET /api/geo/lookup/{ip}` | `lookup()` → `_persist_entry()` | 1 |
|
||||
- The pie chart renders on the dashboard, respecting the selected time range and origin filter.
|
||||
- Changing the time range or origin filter re-renders the chart with new data.
|
||||
- The loading and error states from the hook are handled (show `<Spinner>` while loading, `<MessageBar>` on error).
|
||||
- `tsc --noEmit` and ESLint pass.
|
||||
|
||||
The only endpoint that **should** write geo data is `POST /api/geo/re-resolve` (already a POST).
|
||||
---
|
||||
|
||||
### Concurrency / Connection Sharing Issue
|
||||
## Stage 3 — Country Bar Chart (Top 20)
|
||||
|
||||
The app DB connection (`app.state.db`) is a single `aiosqlite.Connection`. aiosqlite serialises operations through a background thread, so concurrent `await db.execute()` calls from different request handlers are queued. This is visible in the log: while the geo persist loop runs its 5,200 INSERT+COMMITs, other requests' `setup_completed` and session-token queries get interleaved between commits. They all complete, but everything is slower because they wait in the queue.
|
||||
### Task 3.1 — Create the `TopCountriesBarChart` component
|
||||
|
||||
This is not a bug to fix right now, but keep it in mind: if you batch the commits (Task 1) and stop writing on GETs (Task 2), the contention problem largely goes away because the long-running write loop no longer exists.
|
||||
**Status:** `done`
|
||||
|
||||
### Test Infrastructure
|
||||
Create `frontend/src/components/TopCountriesBarChart.tsx`. This component renders a **horizontal bar chart (Balkendiagramm)** showing the **top 20 countries by ban count**.
|
||||
|
||||
- **443 tests** currently passing, **82% coverage**.
|
||||
- Tests use `pytest` + `pytest-asyncio` + `httpx.AsyncClient`.
|
||||
- External dependencies (fail2ban socket, ip-api.com) are fully mocked in tests.
|
||||
- Run with: `cd backend && python -m pytest tests/ -x -q`
|
||||
- Lint: `ruff check backend/app/`
|
||||
- Types: `mypy --strict` on changed files
|
||||
- All code must follow rules in `Docs/Backend-Development.md`.
|
||||
**Data source:** Same `countries` and `country_names` maps from `BansByCountryResponse` — passed as props identical to the pie chart.
|
||||
|
||||
### What NOT to Do
|
||||
**Aggregation logic (frontend):**
|
||||
|
||||
1. **Do not add a second DB connection** to "fix" the concurrency issue. The single-connection model is intentional for SQLite (WAL mode notwithstanding). Batching commits is the correct fix.
|
||||
2. **Do not remove the SQLite geo_cache entirely.** It serves a real purpose: surviving process restarts without re-fetching thousands of IPs from ip-api.com.
|
||||
3. **Do not cache geo data in Redis or add a new dependency.** The two-tier cache (in-memory dict + SQLite) is the right architecture for this app's scale. The problem is purely commit frequency.
|
||||
4. **Do not change the `_cache` dict to an LRU or TTL cache.** The current eviction strategy (flush at 50,000 entries) is fine. The issue is the persistent layer, not the in-memory layer.
|
||||
5. **Do not skip writing test cases.** The project enforces >80% coverage. Every change needs tests.
|
||||
1. Sort the `countries` entries descending by ban count.
|
||||
2. Take the top 20 entries.
|
||||
3. No "Other" bucket — the bar chart is detail-focused.
|
||||
|
||||
**Visual requirements:**
|
||||
|
||||
- Use `<BarChart>` (horizontal via `layout="vertical"`) from Recharts with `<Bar>`, `<XAxis>`, `<YAxis>`, `<CartesianGrid>`, and `<Tooltip>`.
|
||||
- Y-axis shows country names (full name from `country_names`, truncated to ~20 chars with ellipsis if needed).
|
||||
- X-axis shows ban count (numeric).
|
||||
- Bars are coloured with the primary colour from the chart theme palette.
|
||||
- Tooltip shows the full country name and exact ban count.
|
||||
- Chart height should be dynamic based on the number of bars (e.g. `barCount * 36px` min), with a reasonable minimum height.
|
||||
- Wrap in a `<ResponsiveContainer>` for width.
|
||||
|
||||
**Props interface:**
|
||||
|
||||
```ts
|
||||
interface TopCountriesBarChartProps {
|
||||
countries: Record<string, number>;
|
||||
countryNames: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Renders up to 20 bars, sorted descending.
|
||||
- Country names readable on the Y-axis; tooltip provides full detail.
|
||||
- Responsive width, dynamic height.
|
||||
- `tsc --noEmit` passes. No `any`. ESLint clean.
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2 — Integrate the bar chart into `DashboardPage`
|
||||
|
||||
**Status:** `done`
|
||||
|
||||
Add the `TopCountriesBarChart` to the dashboard alongside the pie chart.
|
||||
|
||||
**Layout:**
|
||||
|
||||
- The charts section now contains two cards side-by-side in a responsive grid row (the `chartsRow` class from Task 2.3):
|
||||
- Left: **Top Countries** pie chart (Task 2.1).
|
||||
- Right: **Top 20 Countries** bar chart (Task 3.1).
|
||||
- On narrow screens (< 768 px viewport width) the cards should stack vertically.
|
||||
- Both charts consume data from the **same** `useDashboardCountryData` hook call — do not fetch twice.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Both charts render side by side on wide screens, stacked on narrow screens.
|
||||
- A single API call feeds both charts.
|
||||
- Time range / origin filter controls affect both charts.
|
||||
- Loading / error states handled for both.
|
||||
- `tsc --noEmit` and ESLint pass.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Bans-Over-Time Trend Chart
|
||||
|
||||
### Task 4.1 — Add a backend endpoint for time-series ban aggregation
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
The existing endpoints return flat lists or country-aggregated counts but **no time-bucketed series**. A dashboard trend chart needs data grouped into time buckets.
|
||||
|
||||
Create a new endpoint: **`GET /api/dashboard/bans/trend`**.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `range` | `TimeRange` | `"24h"` | Time-range preset. |
|
||||
| `origin` | `BanOrigin \| null` | `null` | Optional filter by ban origin. |
|
||||
|
||||
**Response model** (`BanTrendResponse`):
|
||||
|
||||
```python
|
||||
class BanTrendBucket(BaseModel):
|
||||
timestamp: str # ISO 8601 UTC start of the bucket
|
||||
count: int # Number of bans in this bucket
|
||||
|
||||
class BanTrendResponse(BaseModel):
|
||||
buckets: list[BanTrendBucket]
|
||||
bucket_size: str # Human-readable label: "1h", "6h", "1d", "7d"
|
||||
```
|
||||
|
||||
**Bucket strategy:**
|
||||
|
||||
| Range | Bucket size | Example buckets |
|
||||
|---|---|---|
|
||||
| `24h` | 1 hour | 24 buckets |
|
||||
| `7d` | 6 hours | 28 buckets |
|
||||
| `30d` | 1 day | 30 buckets |
|
||||
| `365d` | 7 days | ~52 buckets |
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Add the Pydantic models to `backend/app/models/ban.py`.
|
||||
- Add the service function in `backend/app/services/ban_service.py`. Query the fail2ban database (`bans` table), group rows by the computed bucket. Use SQL `CAST((banned_at - ?) / bucket_seconds AS INTEGER)` style bucketing.
|
||||
- Add the route in `backend/app/routers/dashboard.py`.
|
||||
- Follow the existing layering: router → service → repository.
|
||||
- Write tests for the new endpoint in `backend/tests/test_routers/` and `backend/tests/test_services/`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- `GET /api/dashboard/bans/trend?range=24h` returns 24 hourly buckets.
|
||||
- Each bucket has a correct ISO 8601 timestamp and count.
|
||||
- Origin filter is applied correctly.
|
||||
- Empty buckets (zero bans) are included so the frontend has a continuous series.
|
||||
- Tests pass and cover happy path + empty data + origin filter.
|
||||
- `ruff check` and `mypy --strict` pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 4.2 — Create the `BanTrendChart` component
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
Create `frontend/src/components/BanTrendChart.tsx`. This component renders an **area/line chart** showing the number of bans over time.
|
||||
|
||||
**Data source:** A new `useBanTrend` hook that calls `GET /api/dashboard/bans/trend`.
|
||||
|
||||
**Visual requirements:**
|
||||
|
||||
- Use `<AreaChart>` (or `<LineChart>`) from Recharts with `<Area>`, `<XAxis>`, `<YAxis>`, `<CartesianGrid>`, `<Tooltip>`.
|
||||
- X-axis: time labels formatted human-readably (e.g. "Mon 14:00", "Mar 5").
|
||||
- Y-axis: ban count.
|
||||
- Area fill with a semi-transparent version of the primary chart colour.
|
||||
- Tooltip shows exact timestamp + count.
|
||||
- Responsive via `<ResponsiveContainer>`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Displays a continuous time-series line with the correct number of data points for each range.
|
||||
- Readable axis labels for all four time ranges.
|
||||
- Responsive.
|
||||
- `tsc --noEmit`, ESLint clean.
|
||||
|
||||
---
|
||||
|
||||
### Task 4.3 — Integrate the trend chart into `DashboardPage`
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
Add the `BanTrendChart` to the dashboard page **above** the two country charts and **below** the `ServerStatusBar`.
|
||||
|
||||
**Layout:**
|
||||
|
||||
- Full-width section card.
|
||||
- Section title: **"Ban Trend"**.
|
||||
- Shares the same `timeRange` and `originFilter` state.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- The trend chart renders on the dashboard showing bans over time.
|
||||
- Responds to time-range and origin-filter changes.
|
||||
- Loading/error states handled.
|
||||
- `tsc --noEmit` and ESLint pass.
|
||||
|
||||
---
|
||||
|
||||
## Stage 5 — Jail Distribution Chart
|
||||
|
||||
### Task 5.1 — Add a backend endpoint for ban counts per jail
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
The existing `GET /api/jails` endpoint returns jail metadata with `status.currently_banned` — but this counts **currently active** bans, not historical bans in the selected time window. The dashboard needs historical ban counts per jail within the selected time range.
|
||||
|
||||
Create a new endpoint: **`GET /api/dashboard/bans/by-jail`**.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `range` | `TimeRange` | `"24h"` | Time-range preset. |
|
||||
| `origin` | `BanOrigin \| null` | `null` | Optional origin filter. |
|
||||
|
||||
**Response model** (`BansByJailResponse`):
|
||||
|
||||
```python
|
||||
class JailBanCount(BaseModel):
|
||||
jail: str
|
||||
count: int
|
||||
|
||||
class BansByJailResponse(BaseModel):
|
||||
jails: list[JailBanCount]
|
||||
total: int
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Query the `bans` table: `SELECT jail, COUNT(*) FROM bans WHERE timestart >= ? GROUP BY jail ORDER BY COUNT(*) DESC`.
|
||||
- Apply origin filter by checking whether `jail == 'blocklist-import'`.
|
||||
- Add models, service function, route, and tests following existing patterns.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Returns jail names with ban counts descending, within the selected time window.
|
||||
- Origin filter works correctly.
|
||||
- Tests covering happy path, empty data, and filter.
|
||||
- `ruff check` and `mypy --strict` pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 5.2 — Create the `JailDistributionChart` component
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
Create `frontend/src/components/JailDistributionChart.tsx`. This component renders a **horizontal bar chart** showing the distribution of bans across jails.
|
||||
|
||||
**Why this is useful and not covered by existing views:** The current Jails page shows configuration details and live counters per jail, but **does not** provide a visual comparison of which jails are catching the most threats within a selectable time window. An admin reviewing the dashboard benefits from an at-a-glance answer to: *"Which services are being attacked most frequently right now?"* — this is fundamentally different from the country-based charts (which answer *"where"*) and from the ban trend (which answers *"when"*). The jail distribution answers **"what service is targeted"** and helps prioritise hardening efforts.
|
||||
|
||||
**Data source:** A new `useJailDistribution` hook calling `GET /api/dashboard/bans/by-jail`.
|
||||
|
||||
**Visual requirements:**
|
||||
|
||||
- Horizontal `<BarChart>` from Recharts.
|
||||
- Y-axis: jail names.
|
||||
- X-axis: ban count.
|
||||
- Colour-coded bars from the chart theme.
|
||||
- Tooltip with jail name and exact count.
|
||||
- Responsive.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Renders one bar per jail, sorted descending.
|
||||
- Responsive.
|
||||
- `tsc --noEmit`, ESLint clean.
|
||||
|
||||
---
|
||||
|
||||
### Task 5.3 — Integrate the jail distribution chart into `DashboardPage`
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
Add the `JailDistributionChart` as a third chart card alongside the two country charts, or in a second chart row below them if space is constrained.
|
||||
|
||||
**Layout decision:**
|
||||
|
||||
- If three cards fit side-by-side at the standard breakpoint, place all three in one row.
|
||||
- Otherwise, use a 2-column + 1-column stacked layout (pie + bar on row 1, jail chart full-width on row 2). Choose whichever looks cleaner.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- The jail distribution chart renders on the dashboard.
|
||||
- Shares time-range and origin-filter controls with the other charts.
|
||||
- Loading/error states handled.
|
||||
- Responsive layout.
|
||||
- `tsc --noEmit` and ESLint pass.
|
||||
|
||||
---
|
||||
|
||||
## Stage 6 — Polish and Final Review
|
||||
|
||||
### Task 6.1 — Ensure consistent loading, error, and empty states across all charts
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
Review all four chart components and ensure:
|
||||
|
||||
1. **Loading state**: Each shows a Fluent UI `<Spinner>` centred in its card while data is fetching.
|
||||
2. **Error state**: Each shows a Fluent UI `<MessageBar intent="error">` with a retry button.
|
||||
3. **Empty state**: When the data set has zero bans, each chart shows a friendly message (e.g. "No bans in this time range") instead of an empty or broken chart.
|
||||
|
||||
Extract a small shared wrapper if three or more charts duplicate the same loading/error/empty pattern (e.g. `ChartCard` or `ChartStateWrapper`).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- All charts handle loading, error, and empty states consistently.
|
||||
- No broken or blank chart renders when data is unavailable.
|
||||
- `tsc --noEmit` and ESLint pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 6.2 — Write frontend tests for chart components
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
Add tests for each chart component to confirm:
|
||||
|
||||
- Correct number of rendered slices/bars given known test data.
|
||||
- "Other" aggregation logic in the pie chart.
|
||||
- Top-N truncation in the bar chart.
|
||||
- Hook re-fetch on prop change.
|
||||
- Loading and error states render the expected UI.
|
||||
|
||||
Follow the project's existing frontend test setup and conventions.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Each chart component has at least one happy-path and one edge-case test.
|
||||
- Tests pass.
|
||||
- ESLint clean.
|
||||
|
||||
---
|
||||
|
||||
### Task 6.3 — Full build and lint check
|
||||
|
||||
**Status:** `not started`
|
||||
|
||||
Run the complete quality-assurance pipeline:
|
||||
|
||||
1. Backend: `ruff check`, `mypy --strict`, `pytest` with coverage.
|
||||
2. Frontend: `tsc --noEmit`, `eslint`, `npm run build`.
|
||||
3. Fix any warnings or errors introduced during stages 1–6.
|
||||
4. Verify overall test coverage remains ≥ 80 %.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Zero lint warnings/errors on both backend and frontend.
|
||||
- All tests pass.
|
||||
- Build artifacts generated successfully.
|
||||
|
||||
---
|
||||
|
||||
369
frontend/package-lock.json
generated
369
frontend/package-lock.json
generated
@@ -14,7 +14,8 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-maps": "^3.0.0"
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
@@ -2667,6 +2668,42 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
@@ -3032,6 +3069,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
@@ -3086,12 +3135,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
|
||||
"integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"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",
|
||||
@@ -3110,12 +3171,48 @@
|
||||
"@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",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"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",
|
||||
@@ -3193,6 +3290,12 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||
@@ -3628,6 +3731,15 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3726,6 +3838,15 @@
|
||||
"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",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
||||
@@ -3744,12 +3865,73 @@
|
||||
"d3-color": "1 - 2"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"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",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
|
||||
@@ -3803,6 +3985,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3841,6 +4029,16 @@
|
||||
"embla-carousel": "8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -4150,6 +4348,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4311,6 +4515,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4812,6 +5026,29 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -4871,6 +5108,57 @@
|
||||
"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",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -5047,6 +5335,12 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -5205,6 +5499,79 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor/node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor/node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor/node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor/node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor/node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-maps": "^3.0.0"
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
|
||||
178
frontend/src/components/TopCountriesBarChart.tsx
Normal file
178
frontend/src/components/TopCountriesBarChart.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* TopCountriesBarChart — horizontal bar chart showing the top 20 countries
|
||||
* by ban count, sorted descending.
|
||||
*/
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
import { tokens, makeStyles } from "@fluentui/react-components";
|
||||
import {
|
||||
CHART_PALETTE,
|
||||
CHART_AXIS_TEXT_TOKEN,
|
||||
CHART_GRID_LINE_TOKEN,
|
||||
resolveFluentToken,
|
||||
} from "../utils/chartTheme";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOP_N = 20;
|
||||
|
||||
/** Maximum chars before truncating a country name on the Y-axis. */
|
||||
const MAX_LABEL_LENGTH = 20;
|
||||
|
||||
/** Height per bar row in pixels. */
|
||||
const BAR_HEIGHT_PX = 36;
|
||||
|
||||
/** Minimum chart height in pixels. */
|
||||
const MIN_CHART_HEIGHT = 180;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TopCountriesBarChartProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** ISO alpha-2 country code → human-readable country name. */
|
||||
countryNames: Record<string, string>;
|
||||
}
|
||||
|
||||
interface BarEntry {
|
||||
/** Full country name used by Tooltip. */
|
||||
fullName: string;
|
||||
/** Truncated name displayed on the Y-axis. */
|
||||
name: string;
|
||||
/** Ban count. */
|
||||
value: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
wrapper: {
|
||||
width: "100%",
|
||||
overflowX: "hidden",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build the sorted top-N dataset from raw country maps. */
|
||||
function buildEntries(
|
||||
countries: Record<string, number>,
|
||||
countryNames: Record<string, string>,
|
||||
): BarEntry[] {
|
||||
return Object.entries(countries)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, TOP_N)
|
||||
.map(([code, count]) => {
|
||||
const full = countryNames[code] ?? code;
|
||||
return {
|
||||
fullName: full,
|
||||
name:
|
||||
full.length > MAX_LABEL_LENGTH
|
||||
? `${full.slice(0, MAX_LABEL_LENGTH)}…`
|
||||
: full,
|
||||
value: count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BarTooltip(
|
||||
props: TooltipContentProps,
|
||||
): React.JSX.Element | null {
|
||||
const { active, payload } = props;
|
||||
if (!active || payload.length === 0) return null;
|
||||
const entry = payload[0];
|
||||
if (entry == null) return null;
|
||||
// `fullName` is stored as an extra field on the payload item.
|
||||
const fullName = (entry.payload as BarEntry).fullName;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
|
||||
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
|
||||
borderRadius: "4px",
|
||||
padding: "8px 12px",
|
||||
color: resolveFluentToken(tokens.colorNeutralForeground1),
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
<strong>{fullName}</strong>
|
||||
<br />
|
||||
{String(entry.value)} ban{entry.value === 1 ? "" : "s"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Horizontal bar chart showing the top 20 countries by ban count.
|
||||
*
|
||||
* @param props - `countries` map and `countryNames` map from the
|
||||
* `/api/dashboard/bans/by-country` response.
|
||||
*/
|
||||
export function TopCountriesBarChart({
|
||||
countries,
|
||||
countryNames,
|
||||
}: TopCountriesBarChartProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
const entries = buildEntries(countries, countryNames);
|
||||
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
|
||||
|
||||
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
|
||||
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
|
||||
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} style={{ height: chartHeight }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
layout="vertical"
|
||||
data={entries}
|
||||
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: axisColour, fontSize: 12 }}
|
||||
axisLine={{ stroke: gridColour }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={140}
|
||||
tick={{ fill: axisColour, fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={BarTooltip} cursor={{ fill: "transparent" }} />
|
||||
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
frontend/src/components/TopCountriesPieChart.tsx
Normal file
169
frontend/src/components/TopCountriesPieChart.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* TopCountriesPieChart — shows the top 4 countries by ban count plus
|
||||
* an "Other" slice aggregating all remaining countries.
|
||||
*/
|
||||
|
||||
import {
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import type { PieLabelRenderProps } from "recharts";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
import { tokens, makeStyles } from "@fluentui/react-components";
|
||||
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOP_N = 4;
|
||||
const OTHER_LABEL = "Other";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TopCountriesPieChartProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** ISO alpha-2 country code → human-readable country name. */
|
||||
countryNames: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SliceData {
|
||||
name: string;
|
||||
value: number;
|
||||
/** Resolved fill colour for this slice. */
|
||||
fill: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
wrapper: {
|
||||
width: "100%",
|
||||
minHeight: "280px",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build the 5-slice dataset from raw country maps, with resolved colours. */
|
||||
function buildSlices(
|
||||
countries: Record<string, number>,
|
||||
countryNames: Record<string, string>,
|
||||
palette: readonly string[],
|
||||
): SliceData[] {
|
||||
const entries = Object.entries(countries).sort(([, a], [, b]) => b - a);
|
||||
const top = entries.slice(0, TOP_N);
|
||||
const rest = entries.slice(TOP_N);
|
||||
|
||||
const slices: SliceData[] = top.map(([code, count], index) => ({
|
||||
name: countryNames[code] ?? code,
|
||||
value: count,
|
||||
fill: palette[index % palette.length] ?? "",
|
||||
}));
|
||||
|
||||
if (rest.length > 0) {
|
||||
const otherTotal = rest.reduce((sum, [, c]) => sum + c, 0);
|
||||
slices.push({
|
||||
name: OTHER_LABEL,
|
||||
value: otherTotal,
|
||||
fill: palette[slices.length % palette.length] ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return slices;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PieTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
||||
const { active, payload } = props;
|
||||
if (!active || payload.length === 0) return null;
|
||||
const entry = payload[0];
|
||||
if (entry == null) return null;
|
||||
const banCount = entry.value;
|
||||
const displayName: string = entry.name?.toString() ?? "";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
|
||||
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
|
||||
borderRadius: "4px",
|
||||
padding: "8px 12px",
|
||||
color: resolveFluentToken(tokens.colorNeutralForeground1),
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
<strong>{displayName}</strong>
|
||||
<br />
|
||||
{banCount != null
|
||||
? `${String(banCount)} ban${banCount === 1 ? "" : "s"}`
|
||||
: ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pie chart showing the top 4 countries by ban count plus an "Other" slice.
|
||||
*
|
||||
* @param props - `countries` map and `countryNames` map from the
|
||||
* `/api/dashboard/bans/by-country` response.
|
||||
*/
|
||||
export function TopCountriesPieChart({
|
||||
countries,
|
||||
countryNames,
|
||||
}: TopCountriesPieChartProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
const resolvedPalette = CHART_PALETTE.map(resolveFluentToken);
|
||||
const slices = buildSlices(countries, countryNames, resolvedPalette);
|
||||
const total = slices.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
/** Format legend entries as "Country Name (xx%)" */
|
||||
const legendFormatter = (value: string): string => {
|
||||
const slice = slices.find((s) => s.name === value);
|
||||
if (slice == null || total === 0) return value;
|
||||
const pct = ((slice.value / total) * 100).toFixed(1);
|
||||
return `${value} (${pct}%)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={90}
|
||||
label={(labelProps: PieLabelRenderProps): string => {
|
||||
const name = labelProps.name ?? "";
|
||||
const percent = labelProps.percent ?? 0;
|
||||
return `${name}: ${(percent * 100).toFixed(0)}%`;
|
||||
}}
|
||||
labelLine={false}
|
||||
/>
|
||||
<Tooltip content={PieTooltip} />
|
||||
<Legend formatter={legendFormatter} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/hooks/useDashboardCountryData.ts
Normal file
95
frontend/src/hooks/useDashboardCountryData.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* `useDashboardCountryData` hook.
|
||||
*
|
||||
* Fetches ban-by-country aggregates for dashboard chart components. Unlike
|
||||
* `useMapData`, this hook has no debouncing or map-specific state.
|
||||
*
|
||||
* Re-fetches automatically when `timeRange` or `origin` changes.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchBansByCountry } from "../api/map";
|
||||
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value shape for {@link useDashboardCountryData}. */
|
||||
export interface UseDashboardCountryDataResult {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** ISO alpha-2 country code → human-readable country name. */
|
||||
countryNames: Record<string, string>;
|
||||
/** All ban records in the selected window. */
|
||||
bans: DashboardBanItem[];
|
||||
/** Total ban count in the window. */
|
||||
total: number;
|
||||
/** True while a fetch is in flight. */
|
||||
isLoading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch and expose ban-by-country data for dashboard charts.
|
||||
*
|
||||
* @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
|
||||
* @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`.
|
||||
* @returns Aggregated country data, ban list, loading state, and error.
|
||||
*/
|
||||
export function useDashboardCountryData(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter,
|
||||
): UseDashboardCountryDataResult {
|
||||
const [countries, setCountries] = useState<Record<string, number>>({});
|
||||
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
||||
const [bans, setBans] = useState<DashboardBanItem[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
// Abort any in-flight request.
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchBansByCountry(timeRange, origin)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCountries(data.countries);
|
||||
setCountryNames(data.country_names);
|
||||
// MapBanItem and DashboardBanItem are structurally identical.
|
||||
setBans(data.bans as DashboardBanItem[]);
|
||||
setTotal(data.total);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch data");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [timeRange, origin]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return { countries, countryNames, bans, total, isLoading, error };
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
ToggleButton,
|
||||
Toolbar,
|
||||
@@ -16,6 +19,9 @@ import {
|
||||
} from "@fluentui/react-components";
|
||||
import { BanTable } from "../components/BanTable";
|
||||
import { ServerStatusBar } from "../components/ServerStatusBar";
|
||||
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
|
||||
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
|
||||
import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
|
||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||
import { BAN_ORIGIN_FILTER_LABELS, TIME_RANGE_LABELS } from "../types/ban";
|
||||
|
||||
@@ -64,6 +70,16 @@ const useStyles = makeStyles({
|
||||
tabContent: {
|
||||
paddingTop: tokens.spacingVerticalS,
|
||||
},
|
||||
chartsRow: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: tokens.spacingHorizontalL,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
chartCard: {
|
||||
flex: "1 1 300px",
|
||||
minWidth: "280px",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,6 +107,9 @@ export function DashboardPage(): React.JSX.Element {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
|
||||
const { countries, countryNames, isLoading: countryLoading, error: countryError } =
|
||||
useDashboardCountryData(timeRange, originFilter);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
@@ -98,6 +117,40 @@ export function DashboardPage(): React.JSX.Element {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<ServerStatusBar />
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Charts section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Top Countries
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.tabContent}>
|
||||
{countryError != null && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{countryError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{countryLoading && countryError == null ? (
|
||||
<Spinner label="Loading chart data…" />
|
||||
) : (
|
||||
<div className={styles.chartsRow}>
|
||||
<div className={styles.chartCard}>
|
||||
<TopCountriesPieChart
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
/>
|
||||
</div> <div className={styles.chartCard}>
|
||||
<TopCountriesBarChart
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
/>
|
||||
</div> </div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Ban list section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
72
frontend/src/utils/chartTheme.ts
Normal file
72
frontend/src/utils/chartTheme.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Chart theme utility — maps Fluent UI v9 design tokens to Recharts-compatible
|
||||
* CSS colour strings.
|
||||
*
|
||||
* Recharts renders SVG elements and sets colour values as SVG attributes, not
|
||||
* CSS properties. SVG attributes do not support CSS custom-property
|
||||
* references (`var(…)`), so token values must be resolved to their actual
|
||||
* colour strings at render time via `getComputedStyle`.
|
||||
*
|
||||
* Call `resolveFluentToken` inside a component (not at module level) so that
|
||||
* the resolved value reflects the theme that is active when the component
|
||||
* renders.
|
||||
*/
|
||||
|
||||
import { tokens } from "@fluentui/react-components";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime resolver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves a Fluent UI v9 token string (e.g. `tokens.colorNeutralForeground2`)
|
||||
* to the literal CSS colour value defined in the active theme.
|
||||
*
|
||||
* @param tokenValue - A Fluent v9 token string such as
|
||||
* `"var(--colorNeutralForeground2)"`.
|
||||
* @returns The resolved colour string (e.g. `"#605e5c"`), or the original
|
||||
* token value if resolution fails.
|
||||
*/
|
||||
export function resolveFluentToken(tokenValue: string): string {
|
||||
const match = /var\((--[^,)]+)/.exec(tokenValue);
|
||||
if (match == null || match[1] == null) return tokenValue;
|
||||
const resolved = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(match[1])
|
||||
.trim();
|
||||
return resolved !== "" ? resolved : tokenValue;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categorical palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Five distinct categorical colours for pie/bar slices and line series,
|
||||
* expressed as Fluent UI v9 CSS custom-property references.
|
||||
*
|
||||
* Resolve at render time with `resolveFluentToken` before passing to
|
||||
* Recharts components.
|
||||
*/
|
||||
export const CHART_PALETTE: readonly string[] = [
|
||||
tokens.colorPaletteBlueBorderActive,
|
||||
tokens.colorPaletteRedBorderActive,
|
||||
tokens.colorPaletteGreenBorderActive,
|
||||
tokens.colorPaletteGoldBorderActive,
|
||||
tokens.colorPalettePurpleBorderActive,
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural colours
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fluent token for axis labels and tick text — resolves per active theme. */
|
||||
export const CHART_AXIS_TEXT_TOKEN: string = tokens.colorNeutralForeground2;
|
||||
|
||||
/** Fluent token for CartesianGrid lines — resolves per active theme. */
|
||||
export const CHART_GRID_LINE_TOKEN: string = tokens.colorNeutralStroke2;
|
||||
|
||||
/** Fluent token for tooltip background — resolves per active theme. */
|
||||
export const CHART_TOOLTIP_BG_TOKEN: string = tokens.colorNeutralBackground1;
|
||||
|
||||
/** Fluent token for tooltip text — resolves per active theme. */
|
||||
export const CHART_TOOLTIP_TEXT_TOKEN: string = tokens.colorNeutralForeground1;
|
||||
Reference in New Issue
Block a user