Add origin field and filter for ban sources (Tasks 1 & 2)
- Task 1: Mark imported blocklist IP addresses
- Add BanOrigin type and _derive_origin() to ban.py model
- Populate origin field in ban_service list_bans() and bans_by_country()
- BanTable and MapPage companion table show origin badge column
- Tests: origin derivation in test_ban_service.py and test_dashboard.py
- Task 2: Add origin filter to dashboard and world map
- ban_service: _origin_sql_filter() helper; origin param on list_bans()
and bans_by_country()
- dashboard router: optional origin query param forwarded to service
- Frontend: BanOriginFilter type + BAN_ORIGIN_FILTER_LABELS in ban.ts
- fetchBans / fetchBansByCountry forward origin to API
- useBans / useMapData accept and pass origin; page resets on change
- BanTable accepts origin prop; DashboardPage adds segmented filter
- MapPage adds origin Select next to time-range picker
- Tests: origin filter assertions in test_ban_service and test_dashboard
This commit is contained in:
@@ -61,9 +61,18 @@ A geographical overview of ban activity.
|
|||||||
|
|
||||||
### Map
|
### Map
|
||||||
|
|
||||||
- A full world map rendered with country outlines only (no fill colours, no satellite imagery).
|
- A full world map rendered with country outlines, showing ban activity through color-coded fills (no satellite imagery).
|
||||||
- For every country that has at least one banned IP in the selected time range, the total count is displayed centred inside that country's borders.
|
- **Color coding:** Countries are colored based on their ban count for the selected time range:
|
||||||
- Countries with zero banned IPs show no number and no label — they remain blank.
|
- **Red:** High ban count (100+ bans by default)
|
||||||
|
- **Yellow:** Medium ban count (50 bans by default)
|
||||||
|
- **Green:** Low ban count (20 bans by default)
|
||||||
|
- **Transparent (no fill):** Zero bans
|
||||||
|
- Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend)
|
||||||
|
- The color threshold values are configurable through the application settings
|
||||||
|
- **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner.
|
||||||
|
- For every country that has bans, the total count is displayed centred inside that country's borders in the selected time range.
|
||||||
|
- Countries with zero banned IPs show no number and no label — they remain blank and transparent.
|
||||||
|
- Clicking a country filters the companion table below to show only bans from that country.
|
||||||
- Time-range selector with the same quick presets:
|
- Time-range selector with the same quick presets:
|
||||||
- Last 24 hours
|
- Last 24 hours
|
||||||
- Last 7 days
|
- Last 7 days
|
||||||
@@ -184,6 +193,16 @@ A page to inspect and modify the fail2ban configuration without leaving the web
|
|||||||
- Set the database purge age — how long historical ban records are kept before automatic cleanup.
|
- Set the database purge age — how long historical ban records are kept before automatic cleanup.
|
||||||
- Set the maximum number of log-line matches stored per ban record in the database.
|
- Set the maximum number of log-line matches stored per ban record in the database.
|
||||||
|
|
||||||
|
### Map Settings
|
||||||
|
|
||||||
|
- Configure the three color thresholds that determine how countries are colored on the World Map view based on their ban count:
|
||||||
|
- **Low Threshold (Green):** Ban count at which the color transitions from light green to full green (default: 20).
|
||||||
|
- **Medium Threshold (Yellow):** Ban count at which the color transitions from green to yellow (default: 50).
|
||||||
|
- **High Threshold (Red):** Ban count at which the color transitions from yellow to red (default: 100).
|
||||||
|
- Countries with ban counts between thresholds display smoothly interpolated colors.
|
||||||
|
- Countries with zero bans remain transparent (no fill).
|
||||||
|
- Changes take effect immediately on the World Map view without requiring a page reload.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Ban History
|
## 7. Ban History
|
||||||
|
|||||||
240
Docs/Tasks.md
240
Docs/Tasks.md
@@ -4,21 +4,239 @@ This document breaks the entire BanGUI project into development stages, ordered
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ fix: blocklist import — Jail not found (DONE)
|
## Task 1 — Mark Imported Blocklist IP Addresses ✅ DONE
|
||||||
|
|
||||||
**Problem:** Triggering a blocklist import failed with `Jail not found: 'blocklist-import'` because
|
**Completed:** Added `origin` field (`"blocklist"` / `"selfblock"`) to `Ban` and `DashboardBanItem` models, derived from jail name in `ban_service.py`. BanTable and MapPage companion table display an Origin badge column. Tests added to `test_ban_service.py` and `test_dashboard.py`.
|
||||||
the dedicated fail2ban jail did not exist in the dev configuration.
|
|
||||||
|
|
||||||
**Root cause:** `Docker/fail2ban-dev-config/fail2ban/jail.d/` had no `blocklist-import.conf` jail.
|
### Problem
|
||||||
The service code (`blocklist_service.BLOCKLIST_JAIL = "blocklist-import"`) is correct, but the
|
|
||||||
matching jail was never defined.
|
|
||||||
|
|
||||||
**Fix:**
|
When IPs are imported from an external blocklist they are applied to the `blocklist-import` jail via the fail2ban socket. Once they land in the fail2ban SQLite `bans` table they look identical to IPs that were banned organically by fail2ban filters. There is no way for the dashboard or map to tell the user whether a given ban came from a blocklist import or from a real failed-login detection.
|
||||||
- Added `Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf` — a manual-ban jail
|
|
||||||
(no log monitoring; accepts `banip` commands only; 1-week bantime; `iptables-allports` action).
|
|
||||||
- Fixed pre-existing trailing-whitespace lint issue in `app/services/setup_service.py`.
|
|
||||||
|
|
||||||
**Verification:** All 19 blocklist service tests pass. `ruff check` and `mypy --strict` are clean.
|
### Goal
|
||||||
|
|
||||||
|
Every ban displayed in the UI must carry a visible **origin** indicator: either `blocklist` (imported from a blocklist source) or `selfblock` (detected by fail2ban itself).
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
**Backend — derive origin from jail name**
|
||||||
|
|
||||||
|
The blocklist import service already uses a dedicated jail called `blocklist-import`. This can be used as the discriminator: any ban whose `jail` column equals `blocklist-import` is a blocklist ban; everything else is a self-block.
|
||||||
|
|
||||||
|
1. **Model change** — In `backend/app/models/ban.py`, add an `origin` field of type `Literal["blocklist", "selfblock"]` to both `Ban` and `DashboardBanItem`. Compute it from the `jail` value during construction (if `jail == "blocklist-import"` → `"blocklist"`, else `"selfblock"`).
|
||||||
|
2. **Service change** — In `backend/app/services/ban_service.py`, make sure `list_bans()` and `bans_by_country()` populate the new `origin` field when building ban objects.
|
||||||
|
3. **API response** — The JSON payloads from `GET /api/dashboard/bans` and `GET /api/dashboard/bans/by-country` already serialise every field of `DashboardBanItem`, so `origin` will appear automatically once the model is updated.
|
||||||
|
|
||||||
|
**Frontend — show origin badge**
|
||||||
|
|
||||||
|
4. **BanTable** — In `frontend/src/components/BanTable.tsx`, add an **Origin** column. Render a small coloured badge: blue label `Blocklist` or grey label `Selfblock`.
|
||||||
|
5. **MapPage detail table** — The table below the world map in `frontend/src/pages/MapPage.tsx` also lists bans. Add the same origin badge column there.
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
6. Add a unit test in `backend/tests/test_services/test_ban_service.py` that inserts bans into a mock fail2ban DB under both jail names (`blocklist-import` and e.g. `sshd`) and asserts the returned objects carry the correct `origin` value.
|
||||||
|
7. Add a router test in `backend/tests/test_routers/test_dashboard.py` that verifies the JSON response contains the `origin` field.
|
||||||
|
|
||||||
|
### Files Touched
|
||||||
|
|
||||||
|
| Layer | File |
|
||||||
|
|-------|------|
|
||||||
|
| Model | `backend/app/models/ban.py` |
|
||||||
|
| Service | `backend/app/services/ban_service.py` |
|
||||||
|
| Frontend | `frontend/src/components/BanTable.tsx` |
|
||||||
|
| Frontend | `frontend/src/pages/MapPage.tsx` |
|
||||||
|
| Tests | `backend/tests/test_services/test_ban_service.py` |
|
||||||
|
| Tests | `backend/tests/test_routers/test_dashboard.py` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Task 2 — Add Origin Filter to Dashboard and World Map ✅ DONE
|
||||||
|
|
||||||
|
**Completed:** Added optional `origin` query parameter (`"blocklist"` / `"selfblock"`) to both dashboard router endpoints, filtered via `jail`-based WHERE clause in `ban_service`. Frontend: `BanOriginFilter` type + `BAN_ORIGIN_FILTER_LABELS` in `types/ban.ts`; `fetchBans` and `fetchBansByCountry` APIs forward the param; `useBans` and `useMapData` hooks accept `origin`; `DashboardPage` and `MapPage` show a segmented toggle / select for All / Blocklist / Selfblock. Tests extended in `test_ban_service.py` and `test_dashboard.py`.
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Once Task 1 exposes the `origin` field, users need a way to filter the view so they can see **all** bans, **only blocklist** bans, or **only self-blocked** bans. This must work on both the dashboard ban table and the world map.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Add a filter dropdown (or segmented toggle) with three options — `All`, `Blocklist`, `Selfblock` — to the dashboard toolbar and the map toolbar. The selection must propagate to the backend so that only matching bans are returned and pagination / country aggregation remain correct.
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
**Backend — add `origin` query parameter**
|
||||||
|
|
||||||
|
1. **Dashboard router** — `GET /api/dashboard/bans` in `backend/app/routers/dashboard.py`: add an optional query parameter `origin: Optional[Literal["blocklist", "selfblock"]] = None`. Pass it through to the service layer.
|
||||||
|
2. **Map router** — `GET /api/dashboard/bans/by-country` in the same router: add the same `origin` parameter.
|
||||||
|
3. **Service layer** — In `backend/app/services/ban_service.py`:
|
||||||
|
- `list_bans()`: when `origin` is provided, append a WHERE clause on the `jail` column (`jail = 'blocklist-import'` for blocklist, `jail != 'blocklist-import'` for selfblock).
|
||||||
|
- `bans_by_country()`: apply the same jail-based filter so that country aggregation only counts matching bans.
|
||||||
|
|
||||||
|
**Frontend — filter controls**
|
||||||
|
|
||||||
|
4. **Shared state** — Create a small shared type `BanOriginFilter = "all" | "blocklist" | "selfblock"` (e.g. in `frontend/src/types/` or inline).
|
||||||
|
5. **DashboardPage** — In `frontend/src/pages/DashboardPage.tsx`, add a dropdown or segmented control next to the existing time-range toolbar. Store the selected value in component state. Pass it to `useBans` hook, which forwards it as the `origin` query parameter.
|
||||||
|
6. **useBans hook** — In `frontend/src/hooks/useBans.ts`, accept an optional `origin` parameter and include it in the API call via `fetchBans()`.
|
||||||
|
7. **API function** — In `frontend/src/api/dashboard.ts`, update `fetchBans()` to accept and forward the `origin` query parameter.
|
||||||
|
8. **MapPage** — In `frontend/src/pages/MapPage.tsx`, add the same dropdown. Pass the selected value to `useMapData` hook.
|
||||||
|
9. **useMapData hook** — In `frontend/src/hooks/useMapData.ts`, accept `origin` and forward it to `fetchBansByCountry()`.
|
||||||
|
10. **Map API function** — In `frontend/src/api/map.ts`, update `fetchBansByCountry()` to include `origin` in the query string.
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
11. Extend `backend/tests/test_services/test_ban_service.py`: insert bans under multiple jails, call `list_bans(origin="blocklist")` and assert only `blocklist-import` jail bans are returned; repeat for `"selfblock"` and `None`.
|
||||||
|
12. Extend `backend/tests/test_routers/test_dashboard.py`: hit `GET /api/dashboard/bans?origin=blocklist` and verify the response only contains blocklist bans.
|
||||||
|
|
||||||
|
### Files Touched
|
||||||
|
|
||||||
|
| Layer | File |
|
||||||
|
|-------|------|
|
||||||
|
| Router | `backend/app/routers/dashboard.py` |
|
||||||
|
| Service | `backend/app/services/ban_service.py` |
|
||||||
|
| Frontend | `frontend/src/pages/DashboardPage.tsx` |
|
||||||
|
| Frontend | `frontend/src/pages/MapPage.tsx` |
|
||||||
|
| Frontend | `frontend/src/hooks/useBans.ts` |
|
||||||
|
| Frontend | `frontend/src/hooks/useMapData.ts` |
|
||||||
|
| Frontend | `frontend/src/api/dashboard.ts` |
|
||||||
|
| Frontend | `frontend/src/api/map.ts` |
|
||||||
|
| Tests | `backend/tests/test_services/test_ban_service.py` |
|
||||||
|
| Tests | `backend/tests/test_routers/test_dashboard.py` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — Performance Optimisation for 10 k+ IPs (Dashboard & World Map)
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
With a large number of banned IPs (> 10 000), both the dashboard and the world map take over 10 seconds to load and become unusable. The root cause is the geo-enrichment step: `geo_service.py` calls the external `ip-api.com` endpoint **one IP at a time** with a 5-second timeout, and the free tier is rate-limited to 45 requests/minute. On a cold cache with 10 k IPs, this would take hours.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Dashboard and world map must load within **2 seconds** for 10 k banned IPs. Write a reproducible benchmark test to prove it.
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
**Backend — persistent geo cache**
|
||||||
|
|
||||||
|
1. **SQLite geo cache table** — In `backend/app/db.py`, add a `geo_cache` table:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS geo_cache (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
country_code TEXT,
|
||||||
|
country_name TEXT,
|
||||||
|
asn TEXT,
|
||||||
|
org TEXT,
|
||||||
|
cached_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
On startup the service loads from this table instead of starting with an empty dict. Lookups hit the in-memory dict first, then fall through to the DB, and only as a last resort call the external API. Successful API responses are written back to both caches.
|
||||||
|
|
||||||
|
2. **Batch lookup via ip-api.com batch endpoint** — The free `ip-api.com` API supports a **batch POST** to `http://ip-api.com/batch` accepting up to 100 IPs per request. Refactor `geo_service.py` to:
|
||||||
|
- Collect all uncached IPs from the requested page.
|
||||||
|
- Send them in chunks of 100 to the batch endpoint.
|
||||||
|
- Parse the JSON array response and populate caches in one pass.
|
||||||
|
This alone reduces 10 000 cold lookups from 10 000 sequential requests to ≈ 100 batch calls.
|
||||||
|
|
||||||
|
3. **Pre-warm cache on import** — In `backend/app/services/blocklist_service.py`, after a successful blocklist import, fire a background task that batch-resolves all newly imported IPs. This way the dashboard never faces a cold cache for blocklist IPs.
|
||||||
|
|
||||||
|
**Backend — limit the country-aggregation query**
|
||||||
|
|
||||||
|
4. In `ban_service.py`, `bans_by_country()` currently loads up to 2 000 bans and enriches every one. Change it to:
|
||||||
|
- Run the aggregation (GROUP BY `jail`, count per jail) **in SQL** directly against the fail2ban DB.
|
||||||
|
- Only enrich the distinct IPs, batch-resolved.
|
||||||
|
- Return the aggregated country → count map without fetching full ban rows.
|
||||||
|
|
||||||
|
**Frontend — virtualised table**
|
||||||
|
|
||||||
|
5. The `BanTable` component currently renders all rows in the DOM. For 10 k+ rows (even paginated at 100), scrolling is fine, but if page sizes are increased or if pagination is removed, install a virtual-scrolling library (e.g. `@tanstack/react-virtual`) and render only visible rows. Alternatively, ensure page size stays capped at ≤ 500 (already enforced) and measure whether DOM performance is acceptable — only virtualise if needed.
|
||||||
|
|
||||||
|
**Frontend — map debounce**
|
||||||
|
|
||||||
|
6. In `WorldMap.tsx`, add a loading skeleton / spinner and debounce the data fetch. If the user switches time ranges rapidly, cancel in-flight requests to avoid piling up stale responses.
|
||||||
|
|
||||||
|
**Performance test**
|
||||||
|
|
||||||
|
7. Write a pytest benchmark in `backend/tests/test_services/test_ban_service_perf.py`:
|
||||||
|
- Seed a temporary fail2ban SQLite with 10 000 synthetic bans (random IPs, mixed jails, spread over 365 days).
|
||||||
|
- Pre-populate the geo cache with matching entries so the test does not hit the network.
|
||||||
|
- Call `list_bans(range="365d", page=1, page_size=100)` and `bans_by_country(range="365d")`.
|
||||||
|
- Assert both return within **2 seconds** wall-clock time.
|
||||||
|
8. Add a manual/integration test script `backend/tests/scripts/seed_10k_bans.py` that inserts 10 000 bans into the real fail2ban dev DB and pre-caches their geo data so developers can visually test dashboard and map load times in the browser.
|
||||||
|
|
||||||
|
### Files Touched
|
||||||
|
|
||||||
|
| Layer | File |
|
||||||
|
|-------|------|
|
||||||
|
| DB schema | `backend/app/db.py` |
|
||||||
|
| Geo service | `backend/app/services/geo_service.py` |
|
||||||
|
| Ban service | `backend/app/services/ban_service.py` |
|
||||||
|
| Blocklist service | `backend/app/services/blocklist_service.py` |
|
||||||
|
| Frontend | `frontend/src/components/BanTable.tsx` |
|
||||||
|
| Frontend | `frontend/src/components/WorldMap.tsx` |
|
||||||
|
| Tests | `backend/tests/test_services/test_ban_service_perf.py` (new) |
|
||||||
|
| Scripts | `backend/tests/scripts/seed_10k_bans.py` (new) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 — Fix Missing Country for Resolved IPs
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Many IPs (e.g. `5.167.71.2`, `5.167.71.3`, `5.167.71.4`) show no country in the dashboard and map. The current `geo_service.py` calls `ip-api.com` with a 5-second timeout and silently returns `None` fields on any error or timeout. Common causes:
|
||||||
|
|
||||||
|
- **Rate limiting** — the free tier allows 45 req/min; once exceeded, responses return `"status": "fail"` and the service caches `None` values, permanently hiding the country.
|
||||||
|
- **Cache poisoning with empty entries** — a failed lookup stores `GeoInfo(country_code=None, ...)` in the in-memory cache. Until the cache is flushed (at 10 000 entries), that IP will always appear without a country.
|
||||||
|
- **External API unreachable** — network issues or container DNS problems cause timeouts that are treated the same as "no data".
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Every IP that has a valid geographic mapping should display its country. Failed lookups must be retried on subsequent requests rather than permanently cached as empty.
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
**Backend — do not cache failed lookups**
|
||||||
|
|
||||||
|
1. In `backend/app/services/geo_service.py`, change the caching logic: only store a result in the in-memory cache (and the new persistent `geo_cache` table from Task 3) when `country_code is not None`. If the API returned a failure or the request timed out, do **not** cache the result so it will be retried on the next request.
|
||||||
|
|
||||||
|
**Backend — negative-cache with short TTL**
|
||||||
|
|
||||||
|
2. To avoid hammering the API for the same failing IP on every request, introduce a separate **negative cache** — a dict mapping IP → timestamp of last failed attempt. Skip re-lookup if the last failure was less than **5 minutes** ago. After 5 minutes the IP becomes eligible for retry.
|
||||||
|
|
||||||
|
**Backend — fallback to a local GeoIP database**
|
||||||
|
|
||||||
|
3. Add `geoip2` (MaxMind GeoLite2) as an optional fallback. If the free `ip-api.com` lookup fails or is rate-limited, attempt a local lookup using the GeoLite2-Country database (`.mmdb` file). This provides offline country resolution for the vast majority of IPv4 addresses.
|
||||||
|
- Add `geoip2` to `backend/pyproject.toml` dependencies.
|
||||||
|
- Download the GeoLite2-Country database during Docker build or via a setup script (requires free MaxMind license key).
|
||||||
|
- In `geo_service.py`, try `ip-api.com` first; on failure, fall back to geoip2; only return `None` if both fail.
|
||||||
|
|
||||||
|
**Backend — bulk re-resolve endpoint**
|
||||||
|
|
||||||
|
4. Add `POST /api/geo/re-resolve` in `backend/app/routers/geo.py` that:
|
||||||
|
- Queries all currently cached IPs with `country_code IS NULL`.
|
||||||
|
- Re-resolves them in batches (using the batch endpoint from Task 3).
|
||||||
|
- Returns a count of newly resolved IPs.
|
||||||
|
- This lets the admin manually fix historical gaps.
|
||||||
|
|
||||||
|
**Frontend — visual indicator for unknown country**
|
||||||
|
|
||||||
|
5. In `BanTable.tsx` and the MapPage detail table, display a small "unknown" icon or `—` placeholder when country is null, instead of leaving the cell empty. Tooltip: "Country could not be resolved — will retry automatically."
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
6. In `backend/tests/test_services/test_geo_service.py`:
|
||||||
|
- Test that a failed lookup is **not** persisted in the positive cache.
|
||||||
|
- Test that a failed lookup **is** stored in the negative cache and skipped for 5 minutes.
|
||||||
|
- Test that after the negative-cache TTL expires, the IP is re-queried.
|
||||||
|
- Mock the geoip2 fallback and verify it is called when ip-api fails.
|
||||||
|
7. In `backend/tests/test_routers/test_geo.py`, test the new `POST /api/geo/re-resolve` endpoint.
|
||||||
|
|
||||||
|
### Files Touched
|
||||||
|
|
||||||
|
| Layer | File |
|
||||||
|
|-------|------|
|
||||||
|
| Geo service | `backend/app/services/geo_service.py` |
|
||||||
|
| Geo router | `backend/app/routers/geo.py` |
|
||||||
|
| Dependencies | `backend/pyproject.toml` |
|
||||||
|
| Frontend | `frontend/src/components/BanTable.tsx` |
|
||||||
|
| Frontend | `frontend/src/pages/MapPage.tsx` |
|
||||||
|
| Tests | `backend/tests/test_services/test_geo_service.py` |
|
||||||
|
| Tests | `backend/tests/test_routers/test_geo.py` |
|
||||||
|
|||||||
@@ -271,12 +271,14 @@ The dashboard uses cards to display key figures (server status, total bans, acti
|
|||||||
|
|
||||||
## 11. World Map View
|
## 11. World Map View
|
||||||
|
|
||||||
- The map renders country outlines only — **no fill colours, no satellite imagery, no terrain shading**.
|
- The map renders country outlines only — **no fill colours, no satellite imagery, no terrain shading**. Countries are transparent with neutral strokes.
|
||||||
|
- **The map is fully interactive:** users can zoom in/out using mouse wheel or pinch gestures, and pan by dragging. Zoom range: 1× (full world) to 8× (regional detail).
|
||||||
|
- **Zoom controls:** Three small buttons overlaid in the top-right corner provide zoom in (+), zoom out (−), and reset view (⟲) functionality. Buttons use `appearance="secondary"` and `size="small"`.
|
||||||
- Countries with banned IPs display a **count badge** centred inside the country polygon. Use `FontSizes.size14` semibold, `themePrimary` colour.
|
- Countries with banned IPs display a **count badge** centred inside the country polygon. Use `FontSizes.size14` semibold, `themePrimary` colour.
|
||||||
- Countries with zero bans remain completely blank — no label, no tint.
|
- Countries with zero bans remain completely blank — no label, no tint.
|
||||||
- On hover: country region gets a subtle `neutralLighterAlt` fill. On click: fill shifts to `themeLighterAlt` and the companion table below filters to that country.
|
- On hover: country region gets a subtle `neutralBackground3` fill (only if the country has data). On click: fill shifts to `brandBackgroundHover` and the companion table below filters to that country. Default state remains transparent.
|
||||||
- The map must have a **light neutral border** (`neutralLight`) around its container, at **Depth 4**.
|
- The map must have a **light neutral border** (`neutralStroke1`) around its container, with `borderRadius.medium`.
|
||||||
- Time-range selector above the map uses `Pivot` with quick presets (24 h, 7 d, 30 d, 365 d).
|
- Time-range selector above the map uses `Select` dropdown with quick presets (24 h, 7 d, 30 d, 365 d).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ class UnbanRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Discriminator literal for the origin of a ban.
|
||||||
|
BanOrigin = Literal["blocklist", "selfblock"]
|
||||||
|
|
||||||
|
#: Jail name used by the blocklist import service.
|
||||||
|
BLOCKLIST_JAIL: str = "blocklist-import"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_origin(jail: str) -> BanOrigin:
|
||||||
|
"""Derive the ban origin from the jail name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jail: The jail that issued the ban.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``"blocklist"`` when the jail is the dedicated blocklist-import
|
||||||
|
jail, ``"selfblock"`` otherwise.
|
||||||
|
"""
|
||||||
|
return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock"
|
||||||
|
|
||||||
|
|
||||||
class Ban(BaseModel):
|
class Ban(BaseModel):
|
||||||
"""Domain model representing a single active or historical ban record."""
|
"""Domain model representing a single active or historical ban record."""
|
||||||
|
|
||||||
@@ -65,6 +85,10 @@ class Ban(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="ISO 3166-1 alpha-2 country code resolved from the IP.",
|
description="ISO 3166-1 alpha-2 country code resolved from the IP.",
|
||||||
)
|
)
|
||||||
|
origin: BanOrigin = Field(
|
||||||
|
...,
|
||||||
|
description="Whether this ban came from a blocklist import or fail2ban itself.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BanResponse(BaseModel):
|
class BanResponse(BaseModel):
|
||||||
@@ -146,6 +170,10 @@ class DashboardBanItem(BaseModel):
|
|||||||
description="Organisation name associated with the IP.",
|
description="Organisation name associated with the IP.",
|
||||||
)
|
)
|
||||||
ban_count: int = Field(..., ge=1, description="How many times this IP was banned.")
|
ban_count: int = Field(..., ge=1, description="How many times this IP was banned.")
|
||||||
|
origin: BanOrigin = Field(
|
||||||
|
...,
|
||||||
|
description="Whether this ban came from a blocklist import or fail2ban itself.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DashboardBanListResponse(BaseModel):
|
class DashboardBanListResponse(BaseModel):
|
||||||
|
|||||||
@@ -169,3 +169,36 @@ class LogPreviewResponse(BaseModel):
|
|||||||
total_lines: int = Field(..., ge=0)
|
total_lines: int = Field(..., ge=0)
|
||||||
matched_count: int = Field(..., ge=0)
|
matched_count: int = Field(..., ge=0)
|
||||||
regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.")
|
regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Map color threshold models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MapColorThresholdsResponse(BaseModel):
|
||||||
|
"""Response for ``GET /api/config/map-thresholds``."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
|
threshold_high: int = Field(
|
||||||
|
..., description="Ban count for red coloring."
|
||||||
|
)
|
||||||
|
threshold_medium: int = Field(
|
||||||
|
..., description="Ban count for yellow coloring."
|
||||||
|
)
|
||||||
|
threshold_low: int = Field(
|
||||||
|
..., description="Ban count for green coloring."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MapColorThresholdsUpdate(BaseModel):
|
||||||
|
"""Payload for ``PUT /api/config/map-thresholds``."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
|
threshold_high: int = Field(..., gt=0, description="Ban count for red.")
|
||||||
|
threshold_medium: int = Field(
|
||||||
|
..., gt=0, description="Ban count for yellow."
|
||||||
|
)
|
||||||
|
threshold_low: int = Field(..., gt=0, description="Ban count for green.")
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ from app.models.config import (
|
|||||||
JailConfigUpdate,
|
JailConfigUpdate,
|
||||||
LogPreviewRequest,
|
LogPreviewRequest,
|
||||||
LogPreviewResponse,
|
LogPreviewResponse,
|
||||||
|
MapColorThresholdsResponse,
|
||||||
|
MapColorThresholdsUpdate,
|
||||||
RegexTestRequest,
|
RegexTestRequest,
|
||||||
RegexTestResponse,
|
RegexTestResponse,
|
||||||
)
|
)
|
||||||
@@ -380,3 +382,83 @@ async def preview_log(
|
|||||||
:class:`~app.models.config.LogPreviewResponse` with per-line results.
|
:class:`~app.models.config.LogPreviewResponse` with per-line results.
|
||||||
"""
|
"""
|
||||||
return await config_service.preview_log(body)
|
return await config_service.preview_log(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Map color thresholds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/map-color-thresholds",
|
||||||
|
response_model=MapColorThresholdsResponse,
|
||||||
|
summary="Get map color threshold configuration",
|
||||||
|
)
|
||||||
|
async def get_map_color_thresholds(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
) -> MapColorThresholdsResponse:
|
||||||
|
"""Return the configured map color thresholds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.MapColorThresholdsResponse` with
|
||||||
|
current thresholds.
|
||||||
|
"""
|
||||||
|
from app.services import setup_service
|
||||||
|
|
||||||
|
high, medium, low = await setup_service.get_map_color_thresholds(
|
||||||
|
request.app.state.db
|
||||||
|
)
|
||||||
|
return MapColorThresholdsResponse(
|
||||||
|
threshold_high=high,
|
||||||
|
threshold_medium=medium,
|
||||||
|
threshold_low=low,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/map-color-thresholds",
|
||||||
|
response_model=MapColorThresholdsResponse,
|
||||||
|
summary="Update map color threshold configuration",
|
||||||
|
)
|
||||||
|
async def update_map_color_thresholds(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
body: MapColorThresholdsUpdate,
|
||||||
|
) -> MapColorThresholdsResponse:
|
||||||
|
"""Update the map color threshold configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
body: New threshold values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.MapColorThresholdsResponse` with
|
||||||
|
updated thresholds.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if validation fails (thresholds not
|
||||||
|
properly ordered).
|
||||||
|
"""
|
||||||
|
from app.services import setup_service
|
||||||
|
|
||||||
|
try:
|
||||||
|
await setup_service.set_map_color_thresholds(
|
||||||
|
request.app.state.db,
|
||||||
|
threshold_high=body.threshold_high,
|
||||||
|
threshold_medium=body.threshold_medium,
|
||||||
|
threshold_low=body.threshold_low,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
|
||||||
|
return MapColorThresholdsResponse(
|
||||||
|
threshold_high=body.threshold_high,
|
||||||
|
threshold_medium=body.threshold_medium,
|
||||||
|
threshold_low=body.threshold_low,
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Query, Request
|
|||||||
|
|
||||||
from app.dependencies import AuthDep
|
from app.dependencies import AuthDep
|
||||||
from app.models.ban import (
|
from app.models.ban import (
|
||||||
|
BanOrigin,
|
||||||
BansByCountryResponse,
|
BansByCountryResponse,
|
||||||
DashboardBanListResponse,
|
DashboardBanListResponse,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
@@ -77,6 +78,10 @@ async def get_dashboard_bans(
|
|||||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||||
page: int = Query(default=1, ge=1, description="1-based page number."),
|
page: int = Query(default=1, ge=1, description="1-based page number."),
|
||||||
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
|
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
|
||||||
|
origin: BanOrigin | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
|
),
|
||||||
) -> DashboardBanListResponse:
|
) -> DashboardBanListResponse:
|
||||||
"""Return a paginated list of bans within the selected time window.
|
"""Return a paginated list of bans within the selected time window.
|
||||||
|
|
||||||
@@ -91,6 +96,7 @@ async def get_dashboard_bans(
|
|||||||
``"365d"``.
|
``"365d"``.
|
||||||
page: 1-based page number.
|
page: 1-based page number.
|
||||||
page_size: Maximum items per page (1–500).
|
page_size: Maximum items per page (1–500).
|
||||||
|
origin: Optional filter by ban origin.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.ban.DashboardBanListResponse` with paginated
|
:class:`~app.models.ban.DashboardBanListResponse` with paginated
|
||||||
@@ -108,6 +114,7 @@ async def get_dashboard_bans(
|
|||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
geo_enricher=_enricher,
|
geo_enricher=_enricher,
|
||||||
|
origin=origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +127,10 @@ async def get_bans_by_country(
|
|||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||||
|
origin: BanOrigin | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
|
),
|
||||||
) -> BansByCountryResponse:
|
) -> BansByCountryResponse:
|
||||||
"""Return ban counts aggregated by ISO country code.
|
"""Return ban counts aggregated by ISO country code.
|
||||||
|
|
||||||
@@ -131,6 +142,7 @@ async def get_bans_by_country(
|
|||||||
request: The incoming request.
|
request: The incoming request.
|
||||||
_auth: Validated session dependency.
|
_auth: Validated session dependency.
|
||||||
range: Time-range preset.
|
range: Time-range preset.
|
||||||
|
origin: Optional filter by ban origin.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.ban.BansByCountryResponse` with per-country
|
:class:`~app.models.ban.BansByCountryResponse` with per-country
|
||||||
@@ -146,5 +158,6 @@ async def get_bans_by_country(
|
|||||||
socket_path,
|
socket_path,
|
||||||
range,
|
range,
|
||||||
geo_enricher=_enricher,
|
geo_enricher=_enricher,
|
||||||
|
origin=origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ import aiosqlite
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from app.models.ban import (
|
from app.models.ban import (
|
||||||
|
BLOCKLIST_JAIL,
|
||||||
TIME_RANGE_SECONDS,
|
TIME_RANGE_SECONDS,
|
||||||
|
BanOrigin,
|
||||||
BansByCountryResponse,
|
BansByCountryResponse,
|
||||||
DashboardBanItem,
|
DashboardBanItem,
|
||||||
DashboardBanListResponse,
|
DashboardBanListResponse,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
|
_derive_origin,
|
||||||
)
|
)
|
||||||
from app.utils.fail2ban_client import Fail2BanClient
|
from app.utils.fail2ban_client import Fail2BanClient
|
||||||
|
|
||||||
@@ -41,6 +44,24 @@ _SOCKET_TIMEOUT: float = 5.0
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
|
||||||
|
"""Return a SQL fragment and its parameters for the origin filter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
origin: ``"blocklist"`` to restrict to the blocklist-import jail,
|
||||||
|
``"selfblock"`` to exclude it, or ``None`` for no restriction.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``(sql_fragment, params)`` pair — the fragment starts with ``" AND"``
|
||||||
|
so it can be appended directly to an existing WHERE clause.
|
||||||
|
"""
|
||||||
|
if origin == "blocklist":
|
||||||
|
return " AND jail = ?", (BLOCKLIST_JAIL,)
|
||||||
|
if origin == "selfblock":
|
||||||
|
return " AND jail != ?", (BLOCKLIST_JAIL,)
|
||||||
|
return "", ()
|
||||||
|
|
||||||
|
|
||||||
def _since_unix(range_: TimeRange) -> int:
|
def _since_unix(range_: TimeRange) -> int:
|
||||||
"""Return the Unix timestamp representing the start of the time window.
|
"""Return the Unix timestamp representing the start of the time window.
|
||||||
|
|
||||||
@@ -148,6 +169,7 @@ async def list_bans(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||||
geo_enricher: Any | None = None,
|
geo_enricher: Any | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
) -> DashboardBanListResponse:
|
) -> DashboardBanListResponse:
|
||||||
"""Return a paginated list of bans within the selected time window.
|
"""Return a paginated list of bans within the selected time window.
|
||||||
|
|
||||||
@@ -164,6 +186,8 @@ async def list_bans(
|
|||||||
(default: ``100``).
|
(default: ``100``).
|
||||||
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
|
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
|
||||||
When supplied every result is enriched with country and ASN data.
|
When supplied every result is enriched with country and ASN data.
|
||||||
|
origin: Optional origin filter — ``"blocklist"`` restricts results to
|
||||||
|
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.ban.DashboardBanListResponse` containing the
|
:class:`~app.models.ban.DashboardBanListResponse` containing the
|
||||||
@@ -172,16 +196,23 @@ async def list_bans(
|
|||||||
since: int = _since_unix(range_)
|
since: int = _since_unix(range_)
|
||||||
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
|
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
|
||||||
offset: int = (page - 1) * effective_page_size
|
offset: int = (page - 1) * effective_page_size
|
||||||
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
|
|
||||||
db_path: str = await _get_fail2ban_db_path(socket_path)
|
db_path: str = await _get_fail2ban_db_path(socket_path)
|
||||||
log.info("ban_service_list_bans", db_path=db_path, since=since, range=range_)
|
log.info(
|
||||||
|
"ban_service_list_bans",
|
||||||
|
db_path=db_path,
|
||||||
|
since=since,
|
||||||
|
range=range_,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
|
||||||
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
|
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
|
||||||
f2b_db.row_factory = aiosqlite.Row
|
f2b_db.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
async with f2b_db.execute(
|
async with f2b_db.execute(
|
||||||
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?",
|
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause,
|
||||||
(since,),
|
(since, *origin_params),
|
||||||
) as cur:
|
) as cur:
|
||||||
count_row = await cur.fetchone()
|
count_row = await cur.fetchone()
|
||||||
total: int = int(count_row[0]) if count_row else 0
|
total: int = int(count_row[0]) if count_row else 0
|
||||||
@@ -189,10 +220,11 @@ async def list_bans(
|
|||||||
async with f2b_db.execute(
|
async with f2b_db.execute(
|
||||||
"SELECT jail, ip, timeofban, bancount, data "
|
"SELECT jail, ip, timeofban, bancount, data "
|
||||||
"FROM bans "
|
"FROM bans "
|
||||||
"WHERE timeofban >= ? "
|
"WHERE timeofban >= ?"
|
||||||
"ORDER BY timeofban DESC "
|
+ origin_clause
|
||||||
|
+ " ORDER BY timeofban DESC "
|
||||||
"LIMIT ? OFFSET ?",
|
"LIMIT ? OFFSET ?",
|
||||||
(since, effective_page_size, offset),
|
(since, *origin_params, effective_page_size, offset),
|
||||||
) as cur:
|
) as cur:
|
||||||
rows = await cur.fetchall()
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
@@ -232,6 +264,7 @@ async def list_bans(
|
|||||||
asn=asn,
|
asn=asn,
|
||||||
org=org,
|
org=org,
|
||||||
ban_count=ban_count,
|
ban_count=ban_count,
|
||||||
|
origin=_derive_origin(jail),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -255,6 +288,7 @@ async def bans_by_country(
|
|||||||
socket_path: str,
|
socket_path: str,
|
||||||
range_: TimeRange,
|
range_: TimeRange,
|
||||||
geo_enricher: Any | None = None,
|
geo_enricher: Any | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
) -> BansByCountryResponse:
|
) -> BansByCountryResponse:
|
||||||
"""Aggregate ban counts per country for the selected time window.
|
"""Aggregate ban counts per country for the selected time window.
|
||||||
|
|
||||||
@@ -266,6 +300,8 @@ async def bans_by_country(
|
|||||||
socket_path: Path to the fail2ban Unix domain socket.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
range_: Time-range preset.
|
range_: Time-range preset.
|
||||||
geo_enricher: Optional async ``(ip) -> GeoInfo | None`` callable.
|
geo_enricher: Optional async ``(ip) -> GeoInfo | None`` callable.
|
||||||
|
origin: Optional origin filter — ``"blocklist"`` restricts results to
|
||||||
|
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.ban.BansByCountryResponse` with per-country
|
:class:`~app.models.ban.BansByCountryResponse` with per-country
|
||||||
@@ -274,15 +310,22 @@ async def bans_by_country(
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
since: int = _since_unix(range_)
|
since: int = _since_unix(range_)
|
||||||
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
db_path: str = await _get_fail2ban_db_path(socket_path)
|
db_path: str = await _get_fail2ban_db_path(socket_path)
|
||||||
log.info("ban_service_bans_by_country", db_path=db_path, since=since, range=range_)
|
log.info(
|
||||||
|
"ban_service_bans_by_country",
|
||||||
|
db_path=db_path,
|
||||||
|
since=since,
|
||||||
|
range=range_,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
|
||||||
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
|
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
|
||||||
f2b_db.row_factory = aiosqlite.Row
|
f2b_db.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
async with f2b_db.execute(
|
async with f2b_db.execute(
|
||||||
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?",
|
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause,
|
||||||
(since,),
|
(since, *origin_params),
|
||||||
) as cur:
|
) as cur:
|
||||||
count_row = await cur.fetchone()
|
count_row = await cur.fetchone()
|
||||||
total: int = int(count_row[0]) if count_row else 0
|
total: int = int(count_row[0]) if count_row else 0
|
||||||
@@ -290,10 +333,11 @@ async def bans_by_country(
|
|||||||
async with f2b_db.execute(
|
async with f2b_db.execute(
|
||||||
"SELECT jail, ip, timeofban, bancount, data "
|
"SELECT jail, ip, timeofban, bancount, data "
|
||||||
"FROM bans "
|
"FROM bans "
|
||||||
"WHERE timeofban >= ? "
|
"WHERE timeofban >= ?"
|
||||||
"ORDER BY timeofban DESC "
|
+ origin_clause
|
||||||
|
+ " ORDER BY timeofban DESC "
|
||||||
"LIMIT ?",
|
"LIMIT ?",
|
||||||
(since, _MAX_GEO_BANS),
|
(since, *origin_params, _MAX_GEO_BANS),
|
||||||
) as cur:
|
) as cur:
|
||||||
rows = await cur.fetchall()
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
@@ -336,6 +380,7 @@ async def bans_by_country(
|
|||||||
asn=asn,
|
asn=asn,
|
||||||
org=org,
|
org=org,
|
||||||
ban_count=int(row["bancount"]),
|
ban_count=int(row["bancount"]),
|
||||||
|
origin=_derive_origin(str(row["jail"])),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
AddLogPathRequest,
|
AddLogPathRequest,
|
||||||
GlobalConfigResponse,
|
GlobalConfigResponse,
|
||||||
@@ -31,9 +34,12 @@ from app.models.config import (
|
|||||||
LogPreviewLine,
|
LogPreviewLine,
|
||||||
LogPreviewRequest,
|
LogPreviewRequest,
|
||||||
LogPreviewResponse,
|
LogPreviewResponse,
|
||||||
|
MapColorThresholdsResponse,
|
||||||
|
MapColorThresholdsUpdate,
|
||||||
RegexTestRequest,
|
RegexTestRequest,
|
||||||
RegexTestResponse,
|
RegexTestResponse,
|
||||||
)
|
)
|
||||||
|
from app.services import setup_service
|
||||||
from app.utils.fail2ban_client import Fail2BanClient
|
from app.utils.fail2ban_client import Fail2BanClient
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
@@ -609,3 +615,46 @@ def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
|
|||||||
if pos > 0 and len(raw_lines) > 1:
|
if pos > 0 and len(raw_lines) > 1:
|
||||||
raw_lines = raw_lines[1:]
|
raw_lines = raw_lines[1:]
|
||||||
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]
|
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Map color thresholds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThresholdsResponse:
|
||||||
|
"""Retrieve the current map color threshold configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection to the application database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`MapColorThresholdsResponse` containing the three threshold values.
|
||||||
|
"""
|
||||||
|
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
||||||
|
return MapColorThresholdsResponse(
|
||||||
|
threshold_high=high,
|
||||||
|
threshold_medium=medium,
|
||||||
|
threshold_low=low,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_map_color_thresholds(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
update: MapColorThresholdsUpdate,
|
||||||
|
) -> None:
|
||||||
|
"""Update the map color threshold configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection to the application database.
|
||||||
|
update: The new threshold values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If validation fails (thresholds must satisfy high > medium > low).
|
||||||
|
"""
|
||||||
|
await setup_service.set_map_color_thresholds(
|
||||||
|
db,
|
||||||
|
threshold_high=update.threshold_high,
|
||||||
|
threshold_medium=update.threshold_medium,
|
||||||
|
threshold_low=update.threshold_low,
|
||||||
|
)
|
||||||
|
|||||||
@@ -447,3 +447,90 @@ class TestPreviewLog:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["total_lines"] == 1
|
assert data["total_lines"] == 1
|
||||||
assert data["matched_count"] == 1
|
assert data["matched_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/config/map-color-thresholds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMapColorThresholds:
|
||||||
|
"""Tests for ``GET /api/config/map-color-thresholds``."""
|
||||||
|
|
||||||
|
async def test_200_returns_thresholds(self, config_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/config/map-color-thresholds returns 200 with current values."""
|
||||||
|
resp = await config_client.get("/api/config/map-color-thresholds")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "threshold_high" in data
|
||||||
|
assert "threshold_medium" in data
|
||||||
|
assert "threshold_low" in data
|
||||||
|
# Should return defaults after setup
|
||||||
|
assert data["threshold_high"] == 100
|
||||||
|
assert data["threshold_medium"] == 50
|
||||||
|
assert data["threshold_low"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PUT /api/config/map-color-thresholds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateMapColorThresholds:
|
||||||
|
"""Tests for ``PUT /api/config/map-color-thresholds``."""
|
||||||
|
|
||||||
|
async def test_200_updates_thresholds(self, config_client: AsyncClient) -> None:
|
||||||
|
"""PUT /api/config/map-color-thresholds returns 200 and updates settings."""
|
||||||
|
update_payload = {
|
||||||
|
"threshold_high": 200,
|
||||||
|
"threshold_medium": 80,
|
||||||
|
"threshold_low": 30,
|
||||||
|
}
|
||||||
|
resp = await config_client.put(
|
||||||
|
"/api/config/map-color-thresholds", json=update_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["threshold_high"] == 200
|
||||||
|
assert data["threshold_medium"] == 80
|
||||||
|
assert data["threshold_low"] == 30
|
||||||
|
|
||||||
|
# Verify the values persist
|
||||||
|
get_resp = await config_client.get("/api/config/map-color-thresholds")
|
||||||
|
assert get_resp.status_code == 200
|
||||||
|
get_data = get_resp.json()
|
||||||
|
assert get_data["threshold_high"] == 200
|
||||||
|
assert get_data["threshold_medium"] == 80
|
||||||
|
assert get_data["threshold_low"] == 30
|
||||||
|
|
||||||
|
async def test_400_for_invalid_order(self, config_client: AsyncClient) -> None:
|
||||||
|
"""PUT /api/config/map-color-thresholds returns 400 if thresholds are misordered."""
|
||||||
|
invalid_payload = {
|
||||||
|
"threshold_high": 50,
|
||||||
|
"threshold_medium": 50,
|
||||||
|
"threshold_low": 20,
|
||||||
|
}
|
||||||
|
resp = await config_client.put(
|
||||||
|
"/api/config/map-color-thresholds", json=invalid_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "high > medium > low" in resp.json()["detail"]
|
||||||
|
|
||||||
|
async def test_400_for_non_positive_values(
|
||||||
|
self, config_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""PUT /api/config/map-color-thresholds returns 422 for non-positive values (Pydantic validation)."""
|
||||||
|
invalid_payload = {
|
||||||
|
"threshold_high": 100,
|
||||||
|
"threshold_medium": 50,
|
||||||
|
"threshold_low": 0,
|
||||||
|
}
|
||||||
|
resp = await config_client.put(
|
||||||
|
"/api/config/map-color-thresholds", json=invalid_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pydantic validates ge=1 constraint before our service code runs
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ def _make_ban_list_response(n: int = 2) -> DashboardBanListResponse:
|
|||||||
asn="AS3320",
|
asn="AS3320",
|
||||||
org="Telekom",
|
org="Telekom",
|
||||||
ban_count=1,
|
ban_count=1,
|
||||||
|
origin="selfblock",
|
||||||
)
|
)
|
||||||
for i in range(n)
|
for i in range(n)
|
||||||
]
|
]
|
||||||
@@ -334,10 +335,11 @@ def _make_bans_by_country_response() -> object:
|
|||||||
asn="AS3320",
|
asn="AS3320",
|
||||||
org="Telekom",
|
org="Telekom",
|
||||||
ban_count=1,
|
ban_count=1,
|
||||||
|
origin="selfblock",
|
||||||
),
|
),
|
||||||
DashboardBanItem(
|
DashboardBanItem(
|
||||||
ip="5.6.7.8",
|
ip="5.6.7.8",
|
||||||
jail="sshd",
|
jail="blocklist-import",
|
||||||
banned_at="2026-03-01T10:05:00+00:00",
|
banned_at="2026-03-01T10:05:00+00:00",
|
||||||
service=None,
|
service=None,
|
||||||
country_code="US",
|
country_code="US",
|
||||||
@@ -345,6 +347,7 @@ def _make_bans_by_country_response() -> object:
|
|||||||
asn="AS15169",
|
asn="AS15169",
|
||||||
org="Google LLC",
|
org="Google LLC",
|
||||||
ban_count=2,
|
ban_count=2,
|
||||||
|
origin="blocklist",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return BansByCountryResponse(
|
return BansByCountryResponse(
|
||||||
@@ -431,3 +434,146 @@ class TestBansByCountry:
|
|||||||
assert body["total"] == 0
|
assert body["total"] == 0
|
||||||
assert body["countries"] == {}
|
assert body["countries"] == {}
|
||||||
assert body["bans"] == []
|
assert body["bans"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Origin field tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardBansOriginField:
|
||||||
|
"""Verify that the ``origin`` field is present in API responses."""
|
||||||
|
|
||||||
|
async def test_origin_present_in_ban_list_items(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Each item in ``/api/dashboard/bans`` carries an ``origin`` field."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.list_bans",
|
||||||
|
new=AsyncMock(return_value=_make_ban_list_response(1)),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans")
|
||||||
|
|
||||||
|
item = response.json()["items"][0]
|
||||||
|
assert "origin" in item
|
||||||
|
assert item["origin"] in ("blocklist", "selfblock")
|
||||||
|
|
||||||
|
async def test_selfblock_origin_serialised_correctly(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""A ban from a non-blocklist jail serialises as ``"selfblock"``."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.list_bans",
|
||||||
|
new=AsyncMock(return_value=_make_ban_list_response(1)),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans")
|
||||||
|
|
||||||
|
item = response.json()["items"][0]
|
||||||
|
assert item["jail"] == "sshd"
|
||||||
|
assert item["origin"] == "selfblock"
|
||||||
|
|
||||||
|
async def test_origin_present_in_bans_by_country(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Each ban in ``/api/dashboard/bans/by-country`` carries an ``origin``."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.bans_by_country",
|
||||||
|
new=AsyncMock(return_value=_make_bans_by_country_response()),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/by-country")
|
||||||
|
|
||||||
|
bans = response.json()["bans"]
|
||||||
|
assert all("origin" in ban for ban in bans)
|
||||||
|
origins = {ban["origin"] for ban in bans}
|
||||||
|
assert origins == {"blocklist", "selfblock"}
|
||||||
|
|
||||||
|
async def test_blocklist_origin_serialised_correctly(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""A ban from the ``blocklist-import`` jail serialises as ``"blocklist"``."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.bans_by_country",
|
||||||
|
new=AsyncMock(return_value=_make_bans_by_country_response()),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/by-country")
|
||||||
|
|
||||||
|
bans = response.json()["bans"]
|
||||||
|
blocklist_ban = next(b for b in bans if b["jail"] == "blocklist-import")
|
||||||
|
assert blocklist_ban["origin"] == "blocklist"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Origin filter query parameter tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestOriginFilterParam:
|
||||||
|
"""Verify that the ``origin`` query parameter is forwarded to the service."""
|
||||||
|
|
||||||
|
async def test_bans_origin_blocklist_forwarded_to_service(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""``?origin=blocklist`` is passed to ``ban_service.list_bans``."""
|
||||||
|
mock_list = AsyncMock(return_value=_make_ban_list_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans?origin=blocklist")
|
||||||
|
|
||||||
|
_, kwargs = mock_list.call_args
|
||||||
|
assert kwargs.get("origin") == "blocklist"
|
||||||
|
|
||||||
|
async def test_bans_origin_selfblock_forwarded_to_service(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""``?origin=selfblock`` is passed to ``ban_service.list_bans``."""
|
||||||
|
mock_list = AsyncMock(return_value=_make_ban_list_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans?origin=selfblock")
|
||||||
|
|
||||||
|
_, kwargs = mock_list.call_args
|
||||||
|
assert kwargs.get("origin") == "selfblock"
|
||||||
|
|
||||||
|
async def test_bans_no_origin_param_defaults_to_none(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Omitting ``origin`` passes ``None`` to the service (no filtering)."""
|
||||||
|
mock_list = AsyncMock(return_value=_make_ban_list_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans")
|
||||||
|
|
||||||
|
_, kwargs = mock_list.call_args
|
||||||
|
assert kwargs.get("origin") is None
|
||||||
|
|
||||||
|
async def test_bans_invalid_origin_returns_422(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""An invalid ``origin`` value returns HTTP 422 Unprocessable Entity."""
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans?origin=invalid")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_by_country_origin_blocklist_forwarded(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""``?origin=blocklist`` is passed to ``ban_service.bans_by_country``."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.bans_by_country", new=mock_fn
|
||||||
|
):
|
||||||
|
await dashboard_client.get(
|
||||||
|
"/api/dashboard/bans/by-country?origin=blocklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, kwargs = mock_fn.call_args
|
||||||
|
assert kwargs.get("origin") == "blocklist"
|
||||||
|
|
||||||
|
async def test_by_country_no_origin_defaults_to_none(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Omitting ``origin`` passes ``None`` to ``bans_by_country``."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.bans_by_country", new=mock_fn
|
||||||
|
):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans/by-country")
|
||||||
|
|
||||||
|
_, kwargs = mock_fn.call_args
|
||||||
|
assert kwargs.get("origin") is None
|
||||||
|
|||||||
@@ -102,6 +102,39 @@ async def f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc]
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mixed_origin_db_path(tmp_path: Path) -> str: # type: ignore[misc]
|
||||||
|
"""Return a database with bans from both blocklist-import and organic jails."""
|
||||||
|
path = str(tmp_path / "fail2ban_mixed_origin.sqlite3")
|
||||||
|
await _create_f2b_db(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"jail": "blocklist-import",
|
||||||
|
"ip": "10.0.0.1",
|
||||||
|
"timeofban": _ONE_HOUR_AGO,
|
||||||
|
"bantime": -1,
|
||||||
|
"bancount": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"jail": "sshd",
|
||||||
|
"ip": "10.0.0.2",
|
||||||
|
"timeofban": _ONE_HOUR_AGO,
|
||||||
|
"bantime": 3600,
|
||||||
|
"bancount": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"jail": "nginx",
|
||||||
|
"ip": "10.0.0.3",
|
||||||
|
"timeofban": _ONE_HOUR_AGO,
|
||||||
|
"bantime": 7200,
|
||||||
|
"bancount": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def empty_f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc]
|
async def empty_f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc]
|
||||||
"""Return the path to a fail2ban SQLite database with no ban records."""
|
"""Return the path to a fail2ban SQLite database with no ban records."""
|
||||||
@@ -299,3 +332,183 @@ class TestListBansPagination:
|
|||||||
result = await ban_service.list_bans("/fake/sock", "7d", page_size=1)
|
result = await ban_service.list_bans("/fake/sock", "7d", page_size=1)
|
||||||
|
|
||||||
assert result.total == 3 # All three bans are within 7d.
|
assert result.total == 3 # All three bans are within 7d.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_bans / bans_by_country — origin derivation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBanOriginDerivation:
|
||||||
|
"""Verify that ban_service correctly derives ``origin`` from jail names."""
|
||||||
|
|
||||||
|
async def test_blocklist_import_jail_yields_blocklist_origin(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Bans from ``blocklist-import`` jail carry ``origin == "blocklist"``."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.list_bans("/fake/sock", "24h")
|
||||||
|
|
||||||
|
blocklist_items = [i for i in result.items if i.jail == "blocklist-import"]
|
||||||
|
assert len(blocklist_items) == 1
|
||||||
|
assert blocklist_items[0].origin == "blocklist"
|
||||||
|
|
||||||
|
async def test_organic_jail_yields_selfblock_origin(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Bans from organic jails (sshd, nginx, …) carry ``origin == "selfblock"``."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.list_bans("/fake/sock", "24h")
|
||||||
|
|
||||||
|
organic_items = [i for i in result.items if i.jail != "blocklist-import"]
|
||||||
|
assert len(organic_items) == 2
|
||||||
|
for item in organic_items:
|
||||||
|
assert item.origin == "selfblock"
|
||||||
|
|
||||||
|
async def test_all_items_carry_origin_field(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Every returned item has an ``origin`` field with a valid value."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.list_bans("/fake/sock", "24h")
|
||||||
|
|
||||||
|
for item in result.items:
|
||||||
|
assert item.origin in ("blocklist", "selfblock")
|
||||||
|
|
||||||
|
async def test_bans_by_country_blocklist_origin(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``bans_by_country`` also derives origin correctly for blocklist bans."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_country("/fake/sock", "24h")
|
||||||
|
|
||||||
|
blocklist_bans = [b for b in result.bans if b.jail == "blocklist-import"]
|
||||||
|
assert len(blocklist_bans) == 1
|
||||||
|
assert blocklist_bans[0].origin == "blocklist"
|
||||||
|
|
||||||
|
async def test_bans_by_country_selfblock_origin(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``bans_by_country`` derives origin correctly for organic jails."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_country("/fake/sock", "24h")
|
||||||
|
|
||||||
|
organic_bans = [b for b in result.bans if b.jail != "blocklist-import"]
|
||||||
|
assert len(organic_bans) == 2
|
||||||
|
for ban in organic_bans:
|
||||||
|
assert ban.origin == "selfblock"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_bans / bans_by_country — origin filter parameter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestOriginFilter:
|
||||||
|
"""Verify that the origin filter correctly restricts results."""
|
||||||
|
|
||||||
|
async def test_list_bans_blocklist_filter_returns_only_blocklist(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``origin='blocklist'`` returns only blocklist-import jail bans."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.list_bans(
|
||||||
|
"/fake/sock", "24h", origin="blocklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 1
|
||||||
|
assert len(result.items) == 1
|
||||||
|
assert result.items[0].jail == "blocklist-import"
|
||||||
|
assert result.items[0].origin == "blocklist"
|
||||||
|
|
||||||
|
async def test_list_bans_selfblock_filter_excludes_blocklist(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``origin='selfblock'`` excludes the blocklist-import jail."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.list_bans(
|
||||||
|
"/fake/sock", "24h", origin="selfblock"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 2
|
||||||
|
assert len(result.items) == 2
|
||||||
|
for item in result.items:
|
||||||
|
assert item.jail != "blocklist-import"
|
||||||
|
assert item.origin == "selfblock"
|
||||||
|
|
||||||
|
async def test_list_bans_no_filter_returns_all(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``origin=None`` applies no jail restriction — all bans returned."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.list_bans("/fake/sock", "24h", origin=None)
|
||||||
|
|
||||||
|
assert result.total == 3
|
||||||
|
|
||||||
|
async def test_bans_by_country_blocklist_filter(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``bans_by_country`` with ``origin='blocklist'`` counts only blocklist bans."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_country(
|
||||||
|
"/fake/sock", "24h", origin="blocklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 1
|
||||||
|
assert all(b.jail == "blocklist-import" for b in result.bans)
|
||||||
|
|
||||||
|
async def test_bans_by_country_selfblock_filter(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``bans_by_country`` with ``origin='selfblock'`` excludes blocklist jails."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_country(
|
||||||
|
"/fake/sock", "24h", origin="selfblock"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 2
|
||||||
|
assert all(b.jail != "blocklist-import" for b in result.bans)
|
||||||
|
|
||||||
|
async def test_bans_by_country_no_filter_returns_all(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``bans_by_country`` with ``origin=None`` returns all bans."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_country(
|
||||||
|
"/fake/sock", "24h", origin=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 3
|
||||||
|
|||||||
@@ -98,6 +98,23 @@ class TestRunSetup:
|
|||||||
with pytest.raises(RuntimeError, match="already been completed"):
|
with pytest.raises(RuntimeError, match="already been completed"):
|
||||||
await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type]
|
await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def test_initializes_map_color_thresholds_with_defaults(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""run_setup() initializes map color thresholds with default values."""
|
||||||
|
await setup_service.run_setup(
|
||||||
|
db,
|
||||||
|
master_password="mypassword1",
|
||||||
|
database_path="bangui.db",
|
||||||
|
fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
|
||||||
|
timezone="UTC",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
)
|
||||||
|
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
||||||
|
assert high == 100
|
||||||
|
assert medium == 50
|
||||||
|
assert low == 20
|
||||||
|
|
||||||
|
|
||||||
class TestGetTimezone:
|
class TestGetTimezone:
|
||||||
async def test_returns_utc_on_fresh_db(self, db: aiosqlite.Connection) -> None:
|
async def test_returns_utc_on_fresh_db(self, db: aiosqlite.Connection) -> None:
|
||||||
@@ -119,6 +136,74 @@ class TestGetTimezone:
|
|||||||
assert await setup_service.get_timezone(db) == "America/New_York"
|
assert await setup_service.get_timezone(db) == "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapColorThresholds:
|
||||||
|
async def test_get_map_color_thresholds_returns_defaults_on_fresh_db(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""get_map_color_thresholds() returns default values on a fresh database."""
|
||||||
|
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
||||||
|
assert high == 100
|
||||||
|
assert medium == 50
|
||||||
|
assert low == 20
|
||||||
|
|
||||||
|
async def test_set_map_color_thresholds_persists_values(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""set_map_color_thresholds() stores and retrieves custom values."""
|
||||||
|
await setup_service.set_map_color_thresholds(
|
||||||
|
db, threshold_high=200, threshold_medium=80, threshold_low=30
|
||||||
|
)
|
||||||
|
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
||||||
|
assert high == 200
|
||||||
|
assert medium == 80
|
||||||
|
assert low == 30
|
||||||
|
|
||||||
|
async def test_set_map_color_thresholds_rejects_non_positive(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""set_map_color_thresholds() raises ValueError for non-positive thresholds."""
|
||||||
|
with pytest.raises(ValueError, match="positive integers"):
|
||||||
|
await setup_service.set_map_color_thresholds(
|
||||||
|
db, threshold_high=100, threshold_medium=50, threshold_low=0
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="positive integers"):
|
||||||
|
await setup_service.set_map_color_thresholds(
|
||||||
|
db, threshold_high=-10, threshold_medium=50, threshold_low=20
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_set_map_color_thresholds_rejects_invalid_order(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
set_map_color_thresholds() rejects invalid ordering.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValueError, match="high > medium > low"):
|
||||||
|
await setup_service.set_map_color_thresholds(
|
||||||
|
db, threshold_high=50, threshold_medium=50, threshold_low=20
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="high > medium > low"):
|
||||||
|
await setup_service.set_map_color_thresholds(
|
||||||
|
db, threshold_high=100, threshold_medium=30, threshold_low=50
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_run_setup_initializes_default_thresholds(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""run_setup() initializes map color thresholds with defaults."""
|
||||||
|
await setup_service.run_setup(
|
||||||
|
db,
|
||||||
|
master_password="mypassword1",
|
||||||
|
database_path="bangui.db",
|
||||||
|
fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
|
||||||
|
timezone="UTC",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
)
|
||||||
|
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
||||||
|
assert high == 100
|
||||||
|
assert medium == 50
|
||||||
|
assert low == 20
|
||||||
|
|
||||||
|
|
||||||
class TestRunSetupAsync:
|
class TestRunSetupAsync:
|
||||||
"""Verify the async/non-blocking bcrypt behavior of run_setup."""
|
"""Verify the async/non-blocking bcrypt behavior of run_setup."""
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type {
|
|||||||
JailConfigUpdate,
|
JailConfigUpdate,
|
||||||
LogPreviewRequest,
|
LogPreviewRequest,
|
||||||
LogPreviewResponse,
|
LogPreviewResponse,
|
||||||
|
MapColorThresholdsResponse,
|
||||||
|
MapColorThresholdsUpdate,
|
||||||
RegexTestRequest,
|
RegexTestRequest,
|
||||||
RegexTestResponse,
|
RegexTestResponse,
|
||||||
ServerSettingsResponse,
|
ServerSettingsResponse,
|
||||||
@@ -119,3 +121,21 @@ export async function flushLogs(
|
|||||||
);
|
);
|
||||||
return resp.message;
|
return resp.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Map color thresholds
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function fetchMapColorThresholds(
|
||||||
|
): Promise<MapColorThresholdsResponse> {
|
||||||
|
return get<MapColorThresholdsResponse>(ENDPOINTS.configMapColorThresholds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMapColorThresholds(
|
||||||
|
update: MapColorThresholdsUpdate
|
||||||
|
): Promise<MapColorThresholdsResponse> {
|
||||||
|
return put<MapColorThresholdsResponse>(
|
||||||
|
ENDPOINTS.configMapColorThresholds,
|
||||||
|
update,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { get } from "./client";
|
import { get } from "./client";
|
||||||
import { ENDPOINTS } from "./endpoints";
|
import { ENDPOINTS } from "./endpoints";
|
||||||
import type { DashboardBanListResponse, TimeRange } from "../types/ban";
|
import type { DashboardBanListResponse, TimeRange, BanOriginFilter } from "../types/ban";
|
||||||
import type { ServerStatusResponse } from "../types/server";
|
import type { ServerStatusResponse } from "../types/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +26,8 @@ export async function fetchServerStatus(): Promise<ServerStatusResponse> {
|
|||||||
* @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
|
* @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
|
||||||
* @param page - 1-based page number (default `1`).
|
* @param page - 1-based page number (default `1`).
|
||||||
* @param pageSize - Items per page (default `100`).
|
* @param pageSize - Items per page (default `100`).
|
||||||
|
* @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"`
|
||||||
|
* (default `"all"`, which omits the parameter entirely).
|
||||||
* @returns Paginated {@link DashboardBanListResponse}.
|
* @returns Paginated {@link DashboardBanListResponse}.
|
||||||
* @throws {ApiError} When the server returns a non-2xx status.
|
* @throws {ApiError} When the server returns a non-2xx status.
|
||||||
*/
|
*/
|
||||||
@@ -33,12 +35,16 @@ export async function fetchBans(
|
|||||||
range: TimeRange,
|
range: TimeRange,
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 100,
|
pageSize = 100,
|
||||||
|
origin: BanOriginFilter = "all",
|
||||||
): Promise<DashboardBanListResponse> {
|
): Promise<DashboardBanListResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
range,
|
range,
|
||||||
page: String(page),
|
page: String(page),
|
||||||
page_size: String(pageSize),
|
page_size: String(pageSize),
|
||||||
});
|
});
|
||||||
|
if (origin !== "all") {
|
||||||
|
params.set("origin", origin);
|
||||||
|
}
|
||||||
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
|
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const ENDPOINTS = {
|
|||||||
configReload: "/config/reload",
|
configReload: "/config/reload",
|
||||||
configRegexTest: "/config/regex-test",
|
configRegexTest: "/config/regex-test",
|
||||||
configPreviewLog: "/config/preview-log",
|
configPreviewLog: "/config/preview-log",
|
||||||
|
configMapColorThresholds: "/config/map-color-thresholds",
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Server settings
|
// Server settings
|
||||||
|
|||||||
@@ -5,15 +5,22 @@
|
|||||||
import { get } from "./client";
|
import { get } from "./client";
|
||||||
import { ENDPOINTS } from "./endpoints";
|
import { ENDPOINTS } from "./endpoints";
|
||||||
import type { BansByCountryResponse, TimeRange } from "../types/map";
|
import type { BansByCountryResponse, TimeRange } from "../types/map";
|
||||||
|
import type { BanOriginFilter } from "../types/ban";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch ban counts aggregated by country for the given time window.
|
* Fetch ban counts aggregated by country for the given time window.
|
||||||
*
|
*
|
||||||
* @param range - Time-range preset.
|
* @param range - Time-range preset.
|
||||||
|
* @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"`
|
||||||
|
* (default `"all"`, which omits the parameter entirely).
|
||||||
*/
|
*/
|
||||||
export async function fetchBansByCountry(
|
export async function fetchBansByCountry(
|
||||||
range: TimeRange = "24h",
|
range: TimeRange = "24h",
|
||||||
|
origin: BanOriginFilter = "all",
|
||||||
): Promise<BansByCountryResponse> {
|
): Promise<BansByCountryResponse> {
|
||||||
const url = `${ENDPOINTS.dashboardBansByCountry}?range=${encodeURIComponent(range)}`;
|
const params = new URLSearchParams({ range });
|
||||||
return get<BansByCountryResponse>(url);
|
if (origin !== "all") {
|
||||||
|
params.set("origin", origin);
|
||||||
|
}
|
||||||
|
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
|
import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
|
||||||
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
||||||
import { useBans } from "../hooks/useBans";
|
import { useBans } from "../hooks/useBans";
|
||||||
import type { DashboardBanItem, TimeRange } from "../types/ban";
|
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -40,6 +40,11 @@ interface BanTableProps {
|
|||||||
* Changing this value triggers a re-fetch.
|
* Changing this value triggers a re-fetch.
|
||||||
*/
|
*/
|
||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
|
/**
|
||||||
|
* Active origin filter — controlled by the parent `DashboardPage`.
|
||||||
|
* Changing this value triggers a re-fetch and resets to page 1.
|
||||||
|
*/
|
||||||
|
origin?: BanOriginFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -159,6 +164,18 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
|
|||||||
renderHeaderCell: () => "Jail",
|
renderHeaderCell: () => "Jail",
|
||||||
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
|
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
|
||||||
}),
|
}),
|
||||||
|
createTableColumn<DashboardBanItem>({
|
||||||
|
columnId: "origin",
|
||||||
|
renderHeaderCell: () => "Origin",
|
||||||
|
renderCell: (item) => (
|
||||||
|
<Badge
|
||||||
|
appearance="tint"
|
||||||
|
color={item.origin === "blocklist" ? "brand" : "informative"}
|
||||||
|
>
|
||||||
|
{item.origin === "blocklist" ? "Blocklist" : "Selfblock"}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
}),
|
||||||
createTableColumn<DashboardBanItem>({
|
createTableColumn<DashboardBanItem>({
|
||||||
columnId: "ban_count",
|
columnId: "ban_count",
|
||||||
renderHeaderCell: () => "Bans",
|
renderHeaderCell: () => "Bans",
|
||||||
@@ -183,10 +200,11 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
|
|||||||
* Data table for the dashboard ban-list view.
|
* Data table for the dashboard ban-list view.
|
||||||
*
|
*
|
||||||
* @param props.timeRange - Active time-range preset from the parent page.
|
* @param props.timeRange - Active time-range preset from the parent page.
|
||||||
|
* @param props.origin - Active origin filter from the parent page.
|
||||||
*/
|
*/
|
||||||
export function BanTable({ timeRange }: BanTableProps): React.JSX.Element {
|
export function BanTable({ timeRange, origin = "all" }: BanTableProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange);
|
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin);
|
||||||
|
|
||||||
const banColumns = buildBanColumns(styles);
|
const banColumns = buildBanColumns(styles);
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
* country filters the companion table.
|
* country filters the companion table.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { ComposableMap, Geography, useGeographies } from "react-simple-maps";
|
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||||
import type { GeoPermissibleObjects } from "d3-geo";
|
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";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
||||||
@@ -27,6 +28,7 @@ const GEO_URL =
|
|||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
mapWrapper: {
|
mapWrapper: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
position: "relative",
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
borderRadius: tokens.borderRadiusMedium,
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
@@ -39,39 +41,37 @@ const useStyles = makeStyles({
|
|||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
|
zoomControls: {
|
||||||
|
position: "absolute",
|
||||||
|
top: tokens.spacingVerticalM,
|
||||||
|
right: tokens.spacingHorizontalM,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: tokens.spacingVerticalXS,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Colour utilities
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Map a ban count to a fill colour intensity. */
|
|
||||||
function getFill(count: number, maxCount: number): string {
|
|
||||||
if (count === 0 || maxCount === 0) return "#E8E8E8";
|
|
||||||
const intensity = count / maxCount;
|
|
||||||
// Interpolate from light amber to deep red
|
|
||||||
const r = Math.round(220 + (220 - 220) * intensity);
|
|
||||||
const g = Math.round(200 - 180 * intensity);
|
|
||||||
const b = Math.round(160 - 160 * intensity);
|
|
||||||
return `rgb(${String(r)},${String(g)},${String(b)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
// GeoLayer — must be rendered inside ComposableMap to access map context
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface GeoLayerProps {
|
interface GeoLayerProps {
|
||||||
countries: Record<string, number>;
|
countries: Record<string, number>;
|
||||||
maxCount: number;
|
|
||||||
selectedCountry: string | null;
|
selectedCountry: string | null;
|
||||||
onSelectCountry: (cc: string | null) => void;
|
onSelectCountry: (cc: string | null) => void;
|
||||||
|
thresholdLow: number;
|
||||||
|
thresholdMedium: number;
|
||||||
|
thresholdHigh: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GeoLayer({
|
function GeoLayer({
|
||||||
countries,
|
countries,
|
||||||
maxCount,
|
|
||||||
selectedCountry,
|
selectedCountry,
|
||||||
onSelectCountry,
|
onSelectCountry,
|
||||||
|
thresholdLow,
|
||||||
|
thresholdMedium,
|
||||||
|
thresholdHigh,
|
||||||
}: GeoLayerProps): React.JSX.Element {
|
}: GeoLayerProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
||||||
@@ -83,11 +83,12 @@ function GeoLayer({
|
|||||||
[selectedCountry, onSelectCountry],
|
[selectedCountry, onSelectCountry],
|
||||||
);
|
);
|
||||||
|
|
||||||
// react-simple-maps types declare `path` as always defined, but it is
|
if (geographies.length === 0) return <></>;
|
||||||
// undefined during early renders before the MapProvider context initialises.
|
|
||||||
// Cast through unknown to reflect the true runtime type and guard safely.
|
// 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;
|
const safePath = path as unknown as typeof path | null;
|
||||||
if (safePath == null) return <></>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -97,12 +98,22 @@ function GeoLayer({
|
|||||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||||
const isSelected = cc !== null && selectedCountry === cc;
|
const isSelected = cc !== null && selectedCountry === cc;
|
||||||
const centroid = path.centroid(geo as unknown as GeoPermissibleObjects);
|
|
||||||
const [cx, cy] = centroid;
|
// Compute the fill color based on ban count
|
||||||
|
const fillColor = getBanCountColor(
|
||||||
const fill = isSelected
|
count,
|
||||||
? tokens.colorBrandBackground
|
thresholdLow,
|
||||||
: getFill(count, maxCount);
|
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 (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -130,26 +141,30 @@ function GeoLayer({
|
|||||||
geography={geo}
|
geography={geo}
|
||||||
style={{
|
style={{
|
||||||
default: {
|
default: {
|
||||||
fill,
|
fill: isSelected ? tokens.colorBrandBackground : fillColor,
|
||||||
stroke: tokens.colorNeutralBackground1,
|
stroke: tokens.colorNeutralStroke2,
|
||||||
strokeWidth: 0.5,
|
strokeWidth: 0.75,
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
hover: {
|
hover: {
|
||||||
fill: cc ? tokens.colorBrandBackgroundHover : fill,
|
fill: isSelected
|
||||||
stroke: tokens.colorNeutralBackground1,
|
? tokens.colorBrandBackgroundHover
|
||||||
strokeWidth: 0.5,
|
: cc && count > 0
|
||||||
|
? tokens.colorNeutralBackground3
|
||||||
|
: fillColor,
|
||||||
|
stroke: tokens.colorNeutralStroke1,
|
||||||
|
strokeWidth: 1,
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
pressed: {
|
pressed: {
|
||||||
fill: tokens.colorBrandBackgroundPressed,
|
fill: cc ? tokens.colorBrandBackgroundPressed : fillColor,
|
||||||
stroke: tokens.colorNeutralBackground1,
|
stroke: tokens.colorBrandStroke1,
|
||||||
strokeWidth: 0.5,
|
strokeWidth: 1,
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{count > 0 && isFinite(cx) && isFinite(cy) && (
|
{count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && (
|
||||||
<text
|
<text
|
||||||
x={cx}
|
x={cx}
|
||||||
y={cy}
|
y={cy}
|
||||||
@@ -179,15 +194,38 @@ export interface WorldMapProps {
|
|||||||
selectedCountry: string | null;
|
selectedCountry: string | null;
|
||||||
/** Called when the user clicks a country or deselects. */
|
/** Called when the user clicks a country or deselects. */
|
||||||
onSelectCountry: (cc: string | null) => void;
|
onSelectCountry: (cc: string | null) => void;
|
||||||
|
/** Ban count threshold for green coloring (default: 20). */
|
||||||
|
thresholdLow?: number;
|
||||||
|
/** Ban count threshold for yellow coloring (default: 50). */
|
||||||
|
thresholdMedium?: number;
|
||||||
|
/** Ban count threshold for red coloring (default: 100). */
|
||||||
|
thresholdHigh?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorldMap({
|
export function WorldMap({
|
||||||
countries,
|
countries,
|
||||||
selectedCountry,
|
selectedCountry,
|
||||||
onSelectCountry,
|
onSelectCountry,
|
||||||
|
thresholdLow = 20,
|
||||||
|
thresholdMedium = 50,
|
||||||
|
thresholdHigh = 100,
|
||||||
}: WorldMapProps): React.JSX.Element {
|
}: WorldMapProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const maxCount = Math.max(0, ...Object.values(countries));
|
const [zoom, setZoom] = useState<number>(1);
|
||||||
|
const [center, setCenter] = useState<[number, number]>([0, 0]);
|
||||||
|
|
||||||
|
const handleZoomIn = (): void => {
|
||||||
|
setZoom((z) => Math.min(z + 0.5, 8));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = (): void => {
|
||||||
|
setZoom((z) => Math.max(z - 0.5, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetView = (): void => {
|
||||||
|
setZoom(1);
|
||||||
|
setCenter([0, 0]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -195,6 +233,40 @@ 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}>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={zoom >= 8}
|
||||||
|
title="Zoom in"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoom <= 1}
|
||||||
|
title="Zoom out"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleResetView}
|
||||||
|
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
||||||
|
title="Reset view"
|
||||||
|
aria-label="Reset view"
|
||||||
|
>
|
||||||
|
⟲
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ComposableMap
|
<ComposableMap
|
||||||
projection="geoMercator"
|
projection="geoMercator"
|
||||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
projectionConfig={{ scale: 130, center: [10, 20] }}
|
||||||
@@ -202,12 +274,25 @@ export function WorldMap({
|
|||||||
height={400}
|
height={400}
|
||||||
style={{ width: "100%", height: "auto" }}
|
style={{ width: "100%", height: "auto" }}
|
||||||
>
|
>
|
||||||
<GeoLayer
|
<ZoomableGroup
|
||||||
countries={countries}
|
zoom={zoom}
|
||||||
maxCount={maxCount}
|
center={center}
|
||||||
selectedCountry={selectedCountry}
|
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
|
||||||
onSelectCountry={onSelectCountry}
|
setZoom(newZoom);
|
||||||
/>
|
setCenter(coordinates);
|
||||||
|
}}
|
||||||
|
minZoom={1}
|
||||||
|
maxZoom={8}
|
||||||
|
>
|
||||||
|
<GeoLayer
|
||||||
|
countries={countries}
|
||||||
|
selectedCountry={selectedCountry}
|
||||||
|
onSelectCountry={onSelectCountry}
|
||||||
|
thresholdLow={thresholdLow}
|
||||||
|
thresholdMedium={thresholdMedium}
|
||||||
|
thresholdHigh={thresholdHigh}
|
||||||
|
/>
|
||||||
|
</ZoomableGroup>
|
||||||
</ComposableMap>
|
</ComposableMap>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchBans } from "../api/dashboard";
|
import { fetchBans } from "../api/dashboard";
|
||||||
import type { DashboardBanItem, TimeRange } from "../types/ban";
|
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
|
||||||
|
|
||||||
/** Items per page for the ban table. */
|
/** Items per page for the ban table. */
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
@@ -33,29 +33,33 @@ export interface UseBansResult {
|
|||||||
/**
|
/**
|
||||||
* Fetch and manage dashboard ban-list data.
|
* Fetch and manage dashboard ban-list data.
|
||||||
*
|
*
|
||||||
* Automatically re-fetches when `timeRange` or `page` changes.
|
* Automatically re-fetches when `timeRange`, `origin`, or `page` changes.
|
||||||
*
|
*
|
||||||
* @param timeRange - Time-range preset that controls how far back to look.
|
* @param timeRange - Time-range preset that controls how far back to look.
|
||||||
|
* @param origin - Origin filter (default `"all"`).
|
||||||
* @returns Current data, pagination state, loading flag, and a `refresh`
|
* @returns Current data, pagination state, loading flag, and a `refresh`
|
||||||
* callback.
|
* callback.
|
||||||
*/
|
*/
|
||||||
export function useBans(timeRange: TimeRange): UseBansResult {
|
export function useBans(
|
||||||
|
timeRange: TimeRange,
|
||||||
|
origin: BanOriginFilter = "all",
|
||||||
|
): UseBansResult {
|
||||||
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
|
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
|
||||||
const [total, setTotal] = useState<number>(0);
|
const [total, setTotal] = useState<number>(0);
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Reset page when time range changes.
|
// Reset page when time range or origin filter changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [timeRange]);
|
}, [timeRange, origin]);
|
||||||
|
|
||||||
const doFetch = useCallback(async (): Promise<void> => {
|
const doFetch = useCallback(async (): Promise<void> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchBans(timeRange, page, PAGE_SIZE);
|
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin);
|
||||||
setBanItems(data.items);
|
setBanItems(data.items);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -63,7 +67,7 @@ export function useBans(timeRange: TimeRange): UseBansResult {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [timeRange, page]);
|
}, [timeRange, page, origin]);
|
||||||
|
|
||||||
// Stable ref to the latest doFetch so the refresh callback is always current.
|
// Stable ref to the latest doFetch so the refresh callback is always current.
|
||||||
const doFetchRef = useRef(doFetch);
|
const doFetchRef = useRef(doFetch);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchBansByCountry } from "../api/map";
|
import { fetchBansByCountry } from "../api/map";
|
||||||
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
|
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
|
||||||
|
import type { BanOriginFilter } from "../types/ban";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Return type
|
// Return type
|
||||||
@@ -31,7 +32,10 @@ export interface UseMapDataResult {
|
|||||||
// Hook
|
// Hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function useMapData(range: TimeRange = "24h"): UseMapDataResult {
|
export function useMapData(
|
||||||
|
range: TimeRange = "24h",
|
||||||
|
origin: BanOriginFilter = "all",
|
||||||
|
): UseMapDataResult {
|
||||||
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -43,7 +47,7 @@ export function useMapData(range: TimeRange = "24h"): UseMapDataResult {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
fetchBansByCountry(range)
|
fetchBansByCountry(range, origin)
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setData(resp);
|
setData(resp);
|
||||||
})
|
})
|
||||||
@@ -55,7 +59,7 @@ export function useMapData(range: TimeRange = "24h"): UseMapDataResult {
|
|||||||
.finally((): void => {
|
.finally((): void => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [range]);
|
}, [range, origin]);
|
||||||
|
|
||||||
useEffect((): (() => void) => {
|
useEffect((): (() => void) => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Jails — per-jail config accordion with inline editing
|
* Jails — per-jail config accordion with inline editing
|
||||||
* Global — global fail2ban settings (log level, DB config)
|
* Global — global fail2ban settings (log level, DB config)
|
||||||
* Server — server-level settings + flush logs
|
* Server — server-level settings + flush logs
|
||||||
|
* Map — map color threshold configuration
|
||||||
* Regex Tester — live pattern tester
|
* Regex Tester — live pattern tester
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -44,10 +45,15 @@ import {
|
|||||||
useRegexTester,
|
useRegexTester,
|
||||||
useServerSettings,
|
useServerSettings,
|
||||||
} from "../hooks/useConfig";
|
} from "../hooks/useConfig";
|
||||||
|
import {
|
||||||
|
fetchMapColorThresholds,
|
||||||
|
updateMapColorThresholds,
|
||||||
|
} from "../api/config";
|
||||||
import type {
|
import type {
|
||||||
GlobalConfigUpdate,
|
GlobalConfigUpdate,
|
||||||
JailConfig,
|
JailConfig,
|
||||||
JailConfigUpdate,
|
JailConfigUpdate,
|
||||||
|
MapColorThresholdsUpdate,
|
||||||
ServerSettingsUpdate,
|
ServerSettingsUpdate,
|
||||||
} from "../types/config";
|
} from "../types/config";
|
||||||
|
|
||||||
@@ -766,6 +772,156 @@ function ServerTab(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MapTab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function MapTab(): React.JSX.Element {
|
||||||
|
const styles = useStyles();
|
||||||
|
const [thresholdHigh, setThresholdHigh] = useState<string>("100");
|
||||||
|
const [thresholdMedium, setThresholdMedium] = useState<string>("50");
|
||||||
|
const [thresholdLow, setThresholdLow] = useState<string>("20");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);
|
||||||
|
|
||||||
|
// Load current thresholds on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const thresholds = await fetchMapColorThresholds();
|
||||||
|
setThresholdHigh(String(thresholds.threshold_high));
|
||||||
|
setThresholdMedium(String(thresholds.threshold_medium));
|
||||||
|
setThresholdLow(String(thresholds.threshold_low));
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({
|
||||||
|
text: err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async (): Promise<void> => {
|
||||||
|
const high = Number(thresholdHigh);
|
||||||
|
const medium = Number(thresholdMedium);
|
||||||
|
const low = Number(thresholdLow);
|
||||||
|
|
||||||
|
if (isNaN(high) || isNaN(medium) || isNaN(low)) {
|
||||||
|
setMessage({ text: "All thresholds must be valid numbers.", type: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (high <= 0 || medium <= 0 || low <= 0) {
|
||||||
|
setMessage({ text: "All thresholds must be positive integers.", type: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(high > medium && medium > low)) {
|
||||||
|
setMessage({
|
||||||
|
text: "Thresholds must satisfy: high > medium > low.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const update: MapColorThresholdsUpdate = {
|
||||||
|
threshold_high: high,
|
||||||
|
threshold_medium: medium,
|
||||||
|
threshold_low: low,
|
||||||
|
};
|
||||||
|
await updateMapColorThresholds(update);
|
||||||
|
setMessage({ text: "Map color thresholds saved successfully.", type: "success" });
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({
|
||||||
|
text: err instanceof ApiError ? err.message : "Failed to save map color thresholds",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spinner label="Loading map settings…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text as="h3" size={500} weight="semibold" block>
|
||||||
|
Map Color Thresholds
|
||||||
|
</Text>
|
||||||
|
<Text as="p" size={300} className={styles.infoText} block style={{ marginBottom: tokens.spacingVerticalM }}>
|
||||||
|
Configure the ban count thresholds that determine country fill colors on the World Map.
|
||||||
|
Countries with zero bans remain transparent. Colors smoothly interpolate between thresholds.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<MessageBar intent={message.type === "error" ? "error" : "success"}>
|
||||||
|
<MessageBarBody>{message.text}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.fieldRowThree}>
|
||||||
|
<Field label="Low Threshold (Green)" required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={thresholdLow}
|
||||||
|
onChange={(_, d) => {
|
||||||
|
setThresholdLow(d.value);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Medium Threshold (Yellow)" required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={thresholdMedium}
|
||||||
|
onChange={(_, d) => {
|
||||||
|
setThresholdMedium(d.value);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="High Threshold (Red)" required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={thresholdHigh}
|
||||||
|
onChange={(_, d) => {
|
||||||
|
setThresholdHigh(d.value);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text as="p" size={200} className={styles.infoText} style={{ marginTop: tokens.spacingVerticalS }}>
|
||||||
|
• 1 to {thresholdLow}: Light green → Full green<br />
|
||||||
|
• {thresholdLow} to {thresholdMedium}: Green → Yellow<br />
|
||||||
|
• {thresholdMedium} to {thresholdHigh}: Yellow → Red<br />
|
||||||
|
• {thresholdHigh}+: Solid red
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className={styles.buttonRow}>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
icon={<Save24Regular />}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save Thresholds"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RegexTesterTab
|
// RegexTesterTab
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -958,7 +1114,7 @@ function RegexTesterTab(): React.JSX.Element {
|
|||||||
// ConfigPage (root)
|
// ConfigPage (root)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type TabValue = "jails" | "global" | "server" | "regex";
|
type TabValue = "jails" | "global" | "server" | "map" | "regex";
|
||||||
|
|
||||||
export function ConfigPage(): React.JSX.Element {
|
export function ConfigPage(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@@ -985,6 +1141,7 @@ export function ConfigPage(): React.JSX.Element {
|
|||||||
<Tab value="jails">Jails</Tab>
|
<Tab value="jails">Jails</Tab>
|
||||||
<Tab value="global">Global</Tab>
|
<Tab value="global">Global</Tab>
|
||||||
<Tab value="server">Server</Tab>
|
<Tab value="server">Server</Tab>
|
||||||
|
<Tab value="map">Map</Tab>
|
||||||
<Tab value="regex">Regex Tester</Tab>
|
<Tab value="regex">Regex Tester</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
@@ -992,6 +1149,7 @@ export function ConfigPage(): React.JSX.Element {
|
|||||||
{tab === "jails" && <JailsTab />}
|
{tab === "jails" && <JailsTab />}
|
||||||
{tab === "global" && <GlobalTab />}
|
{tab === "global" && <GlobalTab />}
|
||||||
{tab === "server" && <ServerTab />}
|
{tab === "server" && <ServerTab />}
|
||||||
|
{tab === "map" && <MapTab />}
|
||||||
{tab === "regex" && <RegexTesterTab />}
|
{tab === "regex" && <RegexTesterTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { BanTable } from "../components/BanTable";
|
import { BanTable } from "../components/BanTable";
|
||||||
import { ServerStatusBar } from "../components/ServerStatusBar";
|
import { ServerStatusBar } from "../components/ServerStatusBar";
|
||||||
import type { TimeRange } from "../types/ban";
|
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||||
import { TIME_RANGE_LABELS } from "../types/ban";
|
import { BAN_ORIGIN_FILTER_LABELS, TIME_RANGE_LABELS } from "../types/ban";
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -73,6 +73,9 @@ const useStyles = makeStyles({
|
|||||||
/** Ordered time-range presets for the toolbar. */
|
/** Ordered time-range presets for the toolbar. */
|
||||||
const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
|
const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
|
||||||
|
|
||||||
|
/** Ordered origin filter options for the toolbar. */
|
||||||
|
const ORIGIN_FILTERS: BanOriginFilter[] = ["all", "blocklist", "selfblock"];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Component
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -86,6 +89,7 @@ const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
|
|||||||
export function DashboardPage(): React.JSX.Element {
|
export function DashboardPage(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
||||||
|
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
@@ -119,11 +123,28 @@ export function DashboardPage(): React.JSX.Element {
|
|||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
))}
|
))}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
|
{/* Origin filter */}
|
||||||
|
<Toolbar aria-label="Origin filter" size="small">
|
||||||
|
{ORIGIN_FILTERS.map((f) => (
|
||||||
|
<ToggleButton
|
||||||
|
key={f}
|
||||||
|
size="small"
|
||||||
|
checked={originFilter === f}
|
||||||
|
onClick={() => {
|
||||||
|
setOriginFilter(f);
|
||||||
|
}}
|
||||||
|
aria-pressed={originFilter === f}
|
||||||
|
>
|
||||||
|
{BAN_ORIGIN_FILTER_LABELS[f]}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</Toolbar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ban table */}
|
{/* Ban table */}
|
||||||
<div className={styles.tabContent}>
|
<div className={styles.tabContent}>
|
||||||
<BanTable timeRange={timeRange} />
|
<BanTable timeRange={timeRange} origin={originFilter} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
* bans when no country is selected).
|
* bans when no country is selected).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
@@ -29,7 +30,10 @@ import {
|
|||||||
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
||||||
import { WorldMap } from "../components/WorldMap";
|
import { WorldMap } from "../components/WorldMap";
|
||||||
import { useMapData } from "../hooks/useMapData";
|
import { useMapData } from "../hooks/useMapData";
|
||||||
|
import { fetchMapColorThresholds } from "../api/config";
|
||||||
import type { TimeRange } from "../types/map";
|
import type { TimeRange } from "../types/map";
|
||||||
|
import type { BanOriginFilter } from "../types/ban";
|
||||||
|
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
@@ -86,10 +90,30 @@ const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
|||||||
export function MapPage(): React.JSX.Element {
|
export function MapPage(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const [range, setRange] = useState<TimeRange>("24h");
|
const [range, setRange] = useState<TimeRange>("24h");
|
||||||
|
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
||||||
|
const [thresholdLow, setThresholdLow] = useState<number>(20);
|
||||||
|
const [thresholdMedium, setThresholdMedium] = useState<number>(50);
|
||||||
|
const [thresholdHigh, setThresholdHigh] = useState<number>(100);
|
||||||
|
|
||||||
const { countries, countryNames, bans, total, loading, error, refresh } =
|
const { countries, countryNames, bans, total, loading, error, refresh } =
|
||||||
useMapData(range);
|
useMapData(range, originFilter);
|
||||||
|
|
||||||
|
// Fetch color thresholds on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadThresholds = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const thresholds = await fetchMapColorThresholds();
|
||||||
|
setThresholdLow(thresholds.threshold_low);
|
||||||
|
setThresholdMedium(thresholds.threshold_medium);
|
||||||
|
setThresholdHigh(thresholds.threshold_high);
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fall back to defaults if fetch fails
|
||||||
|
console.warn("Failed to load map color thresholds:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadThresholds();
|
||||||
|
}, []);
|
||||||
|
|
||||||
/** 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(() => {
|
||||||
@@ -128,6 +152,23 @@ export function MapPage(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* Origin filter */}
|
||||||
|
<Select
|
||||||
|
aria-label="Origin filter"
|
||||||
|
value={originFilter}
|
||||||
|
onChange={(_ev, data): void => {
|
||||||
|
setOriginFilter(data.value as BanOriginFilter);
|
||||||
|
setSelectedCountry(null);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{(["all", "blocklist", "selfblock"] as BanOriginFilter[]).map((f) => (
|
||||||
|
<option key={f} value={f}>
|
||||||
|
{BAN_ORIGIN_FILTER_LABELS[f]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<ArrowCounterclockwiseRegular />}
|
icon={<ArrowCounterclockwiseRegular />}
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
@@ -162,6 +203,9 @@ export function MapPage(): React.JSX.Element {
|
|||||||
countries={countries}
|
countries={countries}
|
||||||
selectedCountry={selectedCountry}
|
selectedCountry={selectedCountry}
|
||||||
onSelectCountry={setSelectedCountry}
|
onSelectCountry={setSelectedCountry}
|
||||||
|
thresholdLow={thresholdLow}
|
||||||
|
thresholdMedium={thresholdMedium}
|
||||||
|
thresholdHigh={thresholdHigh}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -211,13 +255,14 @@ export function MapPage(): React.JSX.Element {
|
|||||||
<TableHeaderCell>Jail</TableHeaderCell>
|
<TableHeaderCell>Jail</TableHeaderCell>
|
||||||
<TableHeaderCell>Banned At</TableHeaderCell>
|
<TableHeaderCell>Banned At</TableHeaderCell>
|
||||||
<TableHeaderCell>Country</TableHeaderCell>
|
<TableHeaderCell>Country</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Origin</TableHeaderCell>
|
||||||
<TableHeaderCell>Times Banned</TableHeaderCell>
|
<TableHeaderCell>Times Banned</TableHeaderCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{visibleBans.length === 0 ? (
|
{visibleBans.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5}>
|
<TableCell colSpan={6}>
|
||||||
<TableCellLayout>
|
<TableCellLayout>
|
||||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
No bans found.
|
No bans found.
|
||||||
@@ -244,6 +289,16 @@ export function MapPage(): React.JSX.Element {
|
|||||||
{ban.country_name ?? ban.country_code ?? "—"}
|
{ban.country_name ?? ban.country_code ?? "—"}
|
||||||
</TableCellLayout>
|
</TableCellLayout>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableCellLayout>
|
||||||
|
<Badge
|
||||||
|
appearance="tint"
|
||||||
|
color={ban.origin === "blocklist" ? "brand" : "informative"}
|
||||||
|
>
|
||||||
|
{ban.origin === "blocklist" ? "Blocklist" : "Selfblock"}
|
||||||
|
</Badge>
|
||||||
|
</TableCellLayout>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TableCellLayout>{String(ban.ban_count)}</TableCellLayout>
|
<TableCellLayout>{String(ban.ban_count)}</TableCellLayout>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -11,6 +11,22 @@
|
|||||||
/** The four supported time-range presets for dashboard views. */
|
/** The four supported time-range presets for dashboard views. */
|
||||||
export type TimeRange = "24h" | "7d" | "30d" | "365d";
|
export type TimeRange = "24h" | "7d" | "30d" | "365d";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter for the origin of a ban.
|
||||||
|
*
|
||||||
|
* - `"all"` — no filter, show all bans.
|
||||||
|
* - `"blocklist"` — only bans from the blocklist-import jail.
|
||||||
|
* - `"selfblock"` — only bans detected by fail2ban itself.
|
||||||
|
*/
|
||||||
|
export type BanOriginFilter = "all" | "blocklist" | "selfblock";
|
||||||
|
|
||||||
|
/** Human-readable labels for each origin filter option. */
|
||||||
|
export const BAN_ORIGIN_FILTER_LABELS: Record<BanOriginFilter, string> = {
|
||||||
|
all: "All",
|
||||||
|
blocklist: "Blocklist",
|
||||||
|
selfblock: "Selfblock",
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Human-readable labels for each time-range preset. */
|
/** Human-readable labels for each time-range preset. */
|
||||||
export const TIME_RANGE_LABELS: Record<TimeRange, string> = {
|
export const TIME_RANGE_LABELS: Record<TimeRange, string> = {
|
||||||
"24h": "Last 24 h",
|
"24h": "Last 24 h",
|
||||||
@@ -47,6 +63,8 @@ export interface DashboardBanItem {
|
|||||||
org: string | null;
|
org: string | null;
|
||||||
/** How many times this IP was banned. */
|
/** How many times this IP was banned. */
|
||||||
ban_count: number;
|
ban_count: number;
|
||||||
|
/** Whether this ban came from a blocklist import or fail2ban itself. */
|
||||||
|
origin: "blocklist" | "selfblock";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -128,3 +128,19 @@ export interface AddLogPathRequest {
|
|||||||
log_path: string;
|
log_path: string;
|
||||||
tail?: boolean;
|
tail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Map Color Thresholds
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MapColorThresholdsResponse {
|
||||||
|
threshold_high: number;
|
||||||
|
threshold_medium: number;
|
||||||
|
threshold_low: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapColorThresholdsUpdate {
|
||||||
|
threshold_high: number;
|
||||||
|
threshold_medium: number;
|
||||||
|
threshold_low: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface MapBanItem {
|
|||||||
asn: string | null;
|
asn: string | null;
|
||||||
org: string | null;
|
org: string | null;
|
||||||
ban_count: number;
|
ban_count: number;
|
||||||
|
/** Whether this ban came from a blocklist import or fail2ban itself. */
|
||||||
|
origin: "blocklist" | "selfblock";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Response from GET /api/dashboard/bans/by-country */
|
/** Response from GET /api/dashboard/bans/by-country */
|
||||||
|
|||||||
99
frontend/src/utils/mapColors.ts
Normal file
99
frontend/src/utils/mapColors.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Map color utilities for World Map visualization.
|
||||||
|
*
|
||||||
|
* Provides color interpolation logic that maps ban counts to colors based on
|
||||||
|
* configurable thresholds. Countries with zero bans remain transparent;
|
||||||
|
* non-zero counts are interpolated through green → yellow → red color stops.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate a value between two numbers.
|
||||||
|
*
|
||||||
|
* @param start - Start value
|
||||||
|
* @param end - End value
|
||||||
|
* @param t - Interpolation factor in [0, 1]
|
||||||
|
* @returns The interpolated value
|
||||||
|
*/
|
||||||
|
function lerp(start: number, end: number, t: number): number {
|
||||||
|
return start + (end - start) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert RGB values to hex color string.
|
||||||
|
*
|
||||||
|
* @param r - Red component (0-255)
|
||||||
|
* @param g - Green component (0-255)
|
||||||
|
* @param b - Blue component (0-255)
|
||||||
|
* @returns Hex color string in format "#RRGGBB"
|
||||||
|
*/
|
||||||
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
const toHex = (n: number): string => {
|
||||||
|
const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16);
|
||||||
|
return hex.length === 1 ? "0" + hex : hex;
|
||||||
|
};
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the fill color for a country based on its ban count.
|
||||||
|
*
|
||||||
|
* Returns "transparent" for zero bans. For non-zero counts, interpolates
|
||||||
|
* through color stops:
|
||||||
|
* - 1 to threshold_low: light green → full green
|
||||||
|
* - threshold_low to threshold_medium: green → yellow
|
||||||
|
* - threshold_medium to threshold_high: yellow → red
|
||||||
|
* - threshold_high or more: solid red
|
||||||
|
*
|
||||||
|
* @param banCount - Number of bans for the country
|
||||||
|
* @param thresholdLow - Ban count for green coloring (default: 20)
|
||||||
|
* @param thresholdMedium - Ban count for yellow coloring (default: 50)
|
||||||
|
* @param thresholdHigh - Ban count for red coloring (default: 100)
|
||||||
|
* @returns Hex color string or "transparent"
|
||||||
|
*/
|
||||||
|
export function getBanCountColor(
|
||||||
|
banCount: number,
|
||||||
|
thresholdLow: number = 20,
|
||||||
|
thresholdMedium: number = 50,
|
||||||
|
thresholdHigh: number = 100,
|
||||||
|
): string {
|
||||||
|
// Zero bans → transparent (no fill)
|
||||||
|
if (banCount === 0) {
|
||||||
|
return "transparent";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color stops
|
||||||
|
const lightGreen = { r: 144, g: 238, b: 144 }; // #90EE90
|
||||||
|
const green = { r: 0, g: 128, b: 0 }; // #008000
|
||||||
|
const yellow = { r: 255, g: 255, b: 0 }; // #FFFF00
|
||||||
|
const red = { r: 220, g: 20, b: 60 }; // #DC143C (crimson)
|
||||||
|
|
||||||
|
// 1 to threshold_low: interpolate light green → green
|
||||||
|
if (banCount <= thresholdLow) {
|
||||||
|
const t = (banCount - 1) / (thresholdLow - 1);
|
||||||
|
const r = lerp(lightGreen.r, green.r, t);
|
||||||
|
const g = lerp(lightGreen.g, green.g, t);
|
||||||
|
const b = lerp(lightGreen.b, green.b, t);
|
||||||
|
return rgbToHex(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// threshold_low to threshold_medium: interpolate green → yellow
|
||||||
|
if (banCount <= thresholdMedium) {
|
||||||
|
const t = (banCount - thresholdLow) / (thresholdMedium - thresholdLow);
|
||||||
|
const r = lerp(green.r, yellow.r, t);
|
||||||
|
const g = lerp(green.g, yellow.g, t);
|
||||||
|
const b = lerp(green.b, yellow.b, t);
|
||||||
|
return rgbToHex(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// threshold_medium to threshold_high: interpolate yellow → red
|
||||||
|
if (banCount <= thresholdHigh) {
|
||||||
|
const t = (banCount - thresholdMedium) / (thresholdHigh - thresholdMedium);
|
||||||
|
const r = lerp(yellow.r, red.r, t);
|
||||||
|
const g = lerp(yellow.g, red.g, t);
|
||||||
|
const b = lerp(yellow.b, red.b, t);
|
||||||
|
return rgbToHex(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// threshold_high or more: solid red
|
||||||
|
return rgbToHex(red.r, red.g, red.b);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user