diff --git a/Docs/Features.md b/Docs/Features.md index 5bc3e63..5a35d47 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -61,9 +61,18 @@ A geographical overview of ban activity. ### Map -- A full world map rendered with country outlines only (no fill colours, 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. -- Countries with zero banned IPs show no number and no label — they remain blank. +- A full world map rendered with country outlines, showing ban activity through color-coded fills (no satellite imagery). +- **Color coding:** Countries are colored based on their ban count for the selected time range: + - **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: - Last 24 hours - 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 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 diff --git a/Docs/Tasks.md b/Docs/Tasks.md index d498887..e30aaf1 100644 --- a/Docs/Tasks.md +++ b/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 -the dedicated fail2ban jail did not exist in the dev configuration. +**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`. -**Root cause:** `Docker/fail2ban-dev-config/fail2ban/jail.d/` had no `blocklist-import.conf` jail. -The service code (`blocklist_service.BLOCKLIST_JAIL = "blocklist-import"`) is correct, but the -matching jail was never defined. +### Problem -**Fix:** -- 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`. +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. -**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` | diff --git a/Docs/Web-Design.md b/Docs/Web-Design.md index 0005da8..c04b3d1 100644 --- a/Docs/Web-Design.md +++ b/Docs/Web-Design.md @@ -271,12 +271,14 @@ The dashboard uses cards to display key figures (server status, total bans, acti ## 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 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. -- The map must have a **light neutral border** (`neutralLight`) around its container, at **Depth 4**. -- Time-range selector above the map uses `Pivot` with quick presets (24 h, 7 d, 30 d, 365 d). +- 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** (`neutralStroke1`) around its container, with `borderRadius.medium`. +- Time-range selector above the map uses `Select` dropdown with quick presets (24 h, 7 d, 30 d, 365 d). --- diff --git a/backend/app/models/ban.py b/backend/app/models/ban.py index 216f3dd..5f78b4a 100644 --- a/backend/app/models/ban.py +++ b/backend/app/models/ban.py @@ -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): """Domain model representing a single active or historical ban record.""" @@ -65,6 +85,10 @@ class Ban(BaseModel): default=None, 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): @@ -146,6 +170,10 @@ class DashboardBanItem(BaseModel): description="Organisation name associated with the IP.", ) 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): diff --git a/backend/app/models/config.py b/backend/app/models/config.py index f7095a2..de3fc25 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -169,3 +169,36 @@ class LogPreviewResponse(BaseModel): total_lines: 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.") + + +# --------------------------------------------------------------------------- +# 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.") diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index c79138e..e1c9509 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -30,6 +30,8 @@ from app.models.config import ( JailConfigUpdate, LogPreviewRequest, LogPreviewResponse, + MapColorThresholdsResponse, + MapColorThresholdsUpdate, RegexTestRequest, RegexTestResponse, ) @@ -380,3 +382,83 @@ async def preview_log( :class:`~app.models.config.LogPreviewResponse` with per-line results. """ 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, + ) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 67331c7..d76e579 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Query, Request from app.dependencies import AuthDep from app.models.ban import ( + BanOrigin, BansByCountryResponse, DashboardBanListResponse, TimeRange, @@ -77,6 +78,10 @@ async def get_dashboard_bans( range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), page: int = Query(default=1, ge=1, description="1-based page number."), page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."), + origin: BanOrigin | None = Query( + default=None, + description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + ), ) -> DashboardBanListResponse: """Return a paginated list of bans within the selected time window. @@ -91,6 +96,7 @@ async def get_dashboard_bans( ``"365d"``. page: 1-based page number. page_size: Maximum items per page (1–500). + origin: Optional filter by ban origin. Returns: :class:`~app.models.ban.DashboardBanListResponse` with paginated @@ -108,6 +114,7 @@ async def get_dashboard_bans( page=page, page_size=page_size, geo_enricher=_enricher, + origin=origin, ) @@ -120,6 +127,10 @@ async def get_bans_by_country( request: Request, _auth: AuthDep, 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: """Return ban counts aggregated by ISO country code. @@ -131,6 +142,7 @@ async def get_bans_by_country( request: The incoming request. _auth: Validated session dependency. range: Time-range preset. + origin: Optional filter by ban origin. Returns: :class:`~app.models.ban.BansByCountryResponse` with per-country @@ -146,5 +158,6 @@ async def get_bans_by_country( socket_path, range, geo_enricher=_enricher, + origin=origin, ) diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index 78ee73b..dc87c5f 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_service.py @@ -18,11 +18,14 @@ import aiosqlite import structlog from app.models.ban import ( + BLOCKLIST_JAIL, TIME_RANGE_SECONDS, + BanOrigin, BansByCountryResponse, DashboardBanItem, DashboardBanListResponse, TimeRange, + _derive_origin, ) 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: """Return the Unix timestamp representing the start of the time window. @@ -148,6 +169,7 @@ async def list_bans( page: int = 1, page_size: int = _DEFAULT_PAGE_SIZE, geo_enricher: Any | None = None, + origin: BanOrigin | None = None, ) -> DashboardBanListResponse: """Return a paginated list of bans within the selected time window. @@ -164,6 +186,8 @@ async def list_bans( (default: ``100``). geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``. 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: :class:`~app.models.ban.DashboardBanListResponse` containing the @@ -172,16 +196,23 @@ async def list_bans( since: int = _since_unix(range_) effective_page_size: int = min(page_size, _MAX_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) - 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: f2b_db.row_factory = aiosqlite.Row async with f2b_db.execute( - "SELECT COUNT(*) FROM bans WHERE timeofban >= ?", - (since,), + "SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause, + (since, *origin_params), ) as cur: count_row = await cur.fetchone() total: int = int(count_row[0]) if count_row else 0 @@ -189,10 +220,11 @@ async def list_bans( async with f2b_db.execute( "SELECT jail, ip, timeofban, bancount, data " "FROM bans " - "WHERE timeofban >= ? " - "ORDER BY timeofban DESC " + "WHERE timeofban >= ?" + + origin_clause + + " ORDER BY timeofban DESC " "LIMIT ? OFFSET ?", - (since, effective_page_size, offset), + (since, *origin_params, effective_page_size, offset), ) as cur: rows = await cur.fetchall() @@ -232,6 +264,7 @@ async def list_bans( asn=asn, org=org, ban_count=ban_count, + origin=_derive_origin(jail), ) ) @@ -255,6 +288,7 @@ async def bans_by_country( socket_path: str, range_: TimeRange, geo_enricher: Any | None = None, + origin: BanOrigin | None = None, ) -> BansByCountryResponse: """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. range_: Time-range preset. geo_enricher: Optional async ``(ip) -> GeoInfo | None`` callable. + origin: Optional origin filter — ``"blocklist"`` restricts results to + the ``blocklist-import`` jail, ``"selfblock"`` excludes it. Returns: :class:`~app.models.ban.BansByCountryResponse` with per-country @@ -274,15 +310,22 @@ async def bans_by_country( import asyncio since: int = _since_unix(range_) + origin_clause, origin_params = _origin_sql_filter(origin) 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: f2b_db.row_factory = aiosqlite.Row async with f2b_db.execute( - "SELECT COUNT(*) FROM bans WHERE timeofban >= ?", - (since,), + "SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause, + (since, *origin_params), ) as cur: count_row = await cur.fetchone() 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( "SELECT jail, ip, timeofban, bancount, data " "FROM bans " - "WHERE timeofban >= ? " - "ORDER BY timeofban DESC " + "WHERE timeofban >= ?" + + origin_clause + + " ORDER BY timeofban DESC " "LIMIT ?", - (since, _MAX_GEO_BANS), + (since, *origin_params, _MAX_GEO_BANS), ) as cur: rows = await cur.fetchall() @@ -336,6 +380,7 @@ async def bans_by_country( asn=asn, org=org, ban_count=int(row["bancount"]), + origin=_derive_origin(str(row["jail"])), ) ) diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 2b7ee40..5488485 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -16,10 +16,13 @@ import asyncio import contextlib import re from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import structlog +if TYPE_CHECKING: + import aiosqlite + from app.models.config import ( AddLogPathRequest, GlobalConfigResponse, @@ -31,9 +34,12 @@ from app.models.config import ( LogPreviewLine, LogPreviewRequest, LogPreviewResponse, + MapColorThresholdsResponse, + MapColorThresholdsUpdate, RegexTestRequest, RegexTestResponse, ) +from app.services import setup_service from app.utils.fail2ban_client import Fail2BanClient 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: raw_lines = raw_lines[1:] 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, + ) diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index dc49ea4..4c12f82 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -447,3 +447,90 @@ class TestPreviewLog: data = resp.json() assert data["total_lines"] == 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 diff --git a/backend/tests/test_routers/test_dashboard.py b/backend/tests/test_routers/test_dashboard.py index d89bb5e..64a21c9 100644 --- a/backend/tests/test_routers/test_dashboard.py +++ b/backend/tests/test_routers/test_dashboard.py @@ -220,6 +220,7 @@ def _make_ban_list_response(n: int = 2) -> DashboardBanListResponse: asn="AS3320", org="Telekom", ban_count=1, + origin="selfblock", ) for i in range(n) ] @@ -334,10 +335,11 @@ def _make_bans_by_country_response() -> object: asn="AS3320", org="Telekom", ban_count=1, + origin="selfblock", ), DashboardBanItem( ip="5.6.7.8", - jail="sshd", + jail="blocklist-import", banned_at="2026-03-01T10:05:00+00:00", service=None, country_code="US", @@ -345,6 +347,7 @@ def _make_bans_by_country_response() -> object: asn="AS15169", org="Google LLC", ban_count=2, + origin="blocklist", ), ] return BansByCountryResponse( @@ -431,3 +434,146 @@ class TestBansByCountry: assert body["total"] == 0 assert body["countries"] == {} 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 diff --git a/backend/tests/test_services/test_ban_service.py b/backend/tests/test_services/test_ban_service.py index fba052c..16e90c2 100644 --- a/backend/tests/test_services/test_ban_service.py +++ b/backend/tests/test_services/test_ban_service.py @@ -102,6 +102,39 @@ async def f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc] 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 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.""" @@ -299,3 +332,183 @@ class TestListBansPagination: result = await ban_service.list_bans("/fake/sock", "7d", page_size=1) 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 diff --git a/backend/tests/test_services/test_setup_service.py b/backend/tests/test_services/test_setup_service.py index 7991b15..2de760c 100644 --- a/backend/tests/test_services/test_setup_service.py +++ b/backend/tests/test_services/test_setup_service.py @@ -98,6 +98,23 @@ class TestRunSetup: with pytest.raises(RuntimeError, match="already been completed"): 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: 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" +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: """Verify the async/non-blocking bcrypt behavior of run_setup.""" diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 5e9019f..ef56932 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -13,6 +13,8 @@ import type { JailConfigUpdate, LogPreviewRequest, LogPreviewResponse, + MapColorThresholdsResponse, + MapColorThresholdsUpdate, RegexTestRequest, RegexTestResponse, ServerSettingsResponse, @@ -119,3 +121,21 @@ export async function flushLogs( ); return resp.message; } + +// --------------------------------------------------------------------------- +// Map color thresholds +// --------------------------------------------------------------------------- + +export async function fetchMapColorThresholds( +): Promise { + return get(ENDPOINTS.configMapColorThresholds); +} + +export async function updateMapColorThresholds( + update: MapColorThresholdsUpdate +): Promise { + return put( + ENDPOINTS.configMapColorThresholds, + update, + ); +} diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index cec9cfd..f9cf011 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -6,7 +6,7 @@ import { get } from "./client"; 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"; /** @@ -26,6 +26,8 @@ export async function fetchServerStatus(): Promise { * @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`. * @param page - 1-based page number (default `1`). * @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}. * @throws {ApiError} When the server returns a non-2xx status. */ @@ -33,12 +35,16 @@ export async function fetchBans( range: TimeRange, page = 1, pageSize = 100, + origin: BanOriginFilter = "all", ): Promise { const params = new URLSearchParams({ range, page: String(page), page_size: String(pageSize), }); + if (origin !== "all") { + params.set("origin", origin); + } return get(`${ENDPOINTS.dashboardBans}?${params.toString()}`); } diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 83a21c2..3398896 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -65,6 +65,7 @@ export const ENDPOINTS = { configReload: "/config/reload", configRegexTest: "/config/regex-test", configPreviewLog: "/config/preview-log", + configMapColorThresholds: "/config/map-color-thresholds", // ------------------------------------------------------------------------- // Server settings diff --git a/frontend/src/api/map.ts b/frontend/src/api/map.ts index 107173f..e6b8eda 100644 --- a/frontend/src/api/map.ts +++ b/frontend/src/api/map.ts @@ -5,15 +5,22 @@ import { get } from "./client"; import { ENDPOINTS } from "./endpoints"; import type { BansByCountryResponse, TimeRange } from "../types/map"; +import type { BanOriginFilter } from "../types/ban"; /** * 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( range: TimeRange = "24h", + origin: BanOriginFilter = "all", ): Promise { - const url = `${ENDPOINTS.dashboardBansByCountry}?range=${encodeURIComponent(range)}`; - return get(url); + const params = new URLSearchParams({ range }); + if (origin !== "all") { + params.set("origin", origin); + } + return get(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`); } diff --git a/frontend/src/components/BanTable.tsx b/frontend/src/components/BanTable.tsx index afac718..1a8ddef 100644 --- a/frontend/src/components/BanTable.tsx +++ b/frontend/src/components/BanTable.tsx @@ -27,7 +27,7 @@ import { import { PageEmpty, PageError, PageLoading } from "./PageFeedback"; import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons"; import { useBans } from "../hooks/useBans"; -import type { DashboardBanItem, TimeRange } from "../types/ban"; +import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban"; // --------------------------------------------------------------------------- // Types @@ -40,6 +40,11 @@ interface BanTableProps { * Changing this value triggers a re-fetch. */ 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): TableColumnDefin renderHeaderCell: () => "Jail", renderCell: (item) => {item.jail}, }), + createTableColumn({ + columnId: "origin", + renderHeaderCell: () => "Origin", + renderCell: (item) => ( + + {item.origin === "blocklist" ? "Blocklist" : "Selfblock"} + + ), + }), createTableColumn({ columnId: "ban_count", renderHeaderCell: () => "Bans", @@ -183,10 +200,11 @@ function buildBanColumns(styles: ReturnType): TableColumnDefin * Data table for the dashboard ban-list view. * * @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 { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange); + const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin); const banColumns = buildBanColumns(styles); diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx index 5cd4c59..2293631 100644 --- a/frontend/src/components/WorldMap.tsx +++ b/frontend/src/components/WorldMap.tsx @@ -7,11 +7,12 @@ * country filters the companion table. */ -import { useCallback } from "react"; -import { ComposableMap, Geography, useGeographies } from "react-simple-maps"; -import { makeStyles, tokens } from "@fluentui/react-components"; +import { useCallback, useState } from "react"; +import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; +import { Button, makeStyles, tokens } from "@fluentui/react-components"; import type { GeoPermissibleObjects } from "d3-geo"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; +import { getBanCountColor } from "../utils/mapColors"; // --------------------------------------------------------------------------- // Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only) @@ -27,6 +28,7 @@ const GEO_URL = const useStyles = makeStyles({ mapWrapper: { width: "100%", + position: "relative", backgroundColor: tokens.colorNeutralBackground2, borderRadius: tokens.borderRadiusMedium, border: `1px solid ${tokens.colorNeutralStroke1}`, @@ -39,39 +41,37 @@ const useStyles = makeStyles({ pointerEvents: "none", userSelect: "none", }, + zoomControls: { + position: "absolute", + top: tokens.spacingVerticalM, + right: tokens.spacingHorizontalM, + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + zIndex: 10, + }, }); -// --------------------------------------------------------------------------- -// Colour utilities -// --------------------------------------------------------------------------- - -/** Map a ban count to a fill colour intensity. */ -function getFill(count: number, maxCount: number): string { - if (count === 0 || maxCount === 0) return "#E8E8E8"; - const intensity = count / maxCount; - // Interpolate from light amber to deep red - const r = Math.round(220 + (220 - 220) * intensity); - const g = Math.round(200 - 180 * intensity); - const b = Math.round(160 - 160 * intensity); - return `rgb(${String(r)},${String(g)},${String(b)})`; -} - // --------------------------------------------------------------------------- // GeoLayer — must be rendered inside ComposableMap to access map context // --------------------------------------------------------------------------- interface GeoLayerProps { countries: Record; - maxCount: number; selectedCountry: string | null; onSelectCountry: (cc: string | null) => void; + thresholdLow: number; + thresholdMedium: number; + thresholdHigh: number; } function GeoLayer({ countries, - maxCount, selectedCountry, onSelectCountry, + thresholdLow, + thresholdMedium, + thresholdHigh, }: GeoLayerProps): React.JSX.Element { const styles = useStyles(); const { geographies, path } = useGeographies({ geography: GEO_URL }); @@ -83,11 +83,12 @@ function GeoLayer({ [selectedCountry, onSelectCountry], ); - // react-simple-maps types declare `path` as always defined, but it is - // undefined during early renders before the MapProvider context initialises. - // Cast through unknown to reflect the true runtime type and guard safely. + if (geographies.length === 0) return <>; + + // react-simple-maps types declare path as always defined, but it can be null + // during initial render before MapProvider context initializes. Cast to reflect + // the true runtime type and allow safe null checking. const safePath = path as unknown as typeof path | null; - if (safePath == null) return <>; return ( <> @@ -97,12 +98,22 @@ function GeoLayer({ const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; const count: number = cc !== null ? (countries[cc] ?? 0) : 0; const isSelected = cc !== null && selectedCountry === cc; - const centroid = path.centroid(geo as unknown as GeoPermissibleObjects); - const [cx, cy] = centroid; - - const fill = isSelected - ? tokens.colorBrandBackground - : getFill(count, maxCount); + + // Compute the fill color based on ban count + const fillColor = getBanCountColor( + count, + thresholdLow, + thresholdMedium, + thresholdHigh, + ); + + // Only calculate centroid if path is available + let cx: number | undefined; + let cy: number | undefined; + if (safePath != null) { + const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects); + [cx, cy] = centroid; + } return ( 0 + ? tokens.colorNeutralBackground3 + : fillColor, + stroke: tokens.colorNeutralStroke1, + strokeWidth: 1, outline: "none", }, pressed: { - fill: tokens.colorBrandBackgroundPressed, - stroke: tokens.colorNeutralBackground1, - strokeWidth: 0.5, + fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, + stroke: tokens.colorBrandStroke1, + strokeWidth: 1, outline: "none", }, }} /> - {count > 0 && isFinite(cx) && isFinite(cy) && ( + {count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && ( void; + /** Ban count threshold for green coloring (default: 20). */ + thresholdLow?: number; + /** Ban count threshold for yellow coloring (default: 50). */ + thresholdMedium?: number; + /** Ban count threshold for red coloring (default: 100). */ + thresholdHigh?: number; } export function WorldMap({ countries, selectedCountry, onSelectCountry, + thresholdLow = 20, + thresholdMedium = 50, + thresholdHigh = 100, }: WorldMapProps): React.JSX.Element { const styles = useStyles(); - const maxCount = Math.max(0, ...Object.values(countries)); + const [zoom, setZoom] = useState(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 (
+ {/* Zoom controls */} +
+ + + +
+ - + { + setZoom(newZoom); + setCenter(coordinates); + }} + minZoom={1} + maxZoom={8} + > + +
); diff --git a/frontend/src/hooks/useBans.ts b/frontend/src/hooks/useBans.ts index 8288176..c3a979f 100644 --- a/frontend/src/hooks/useBans.ts +++ b/frontend/src/hooks/useBans.ts @@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; 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. */ const PAGE_SIZE = 100; @@ -33,29 +33,33 @@ export interface UseBansResult { /** * 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 origin - Origin filter (default `"all"`). * @returns Current data, pagination state, loading flag, and a `refresh` * callback. */ -export function useBans(timeRange: TimeRange): UseBansResult { +export function useBans( + timeRange: TimeRange, + origin: BanOriginFilter = "all", +): UseBansResult { const [banItems, setBanItems] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Reset page when time range changes. + // Reset page when time range or origin filter changes. useEffect(() => { setPage(1); - }, [timeRange]); + }, [timeRange, origin]); const doFetch = useCallback(async (): Promise => { setLoading(true); setError(null); try { - const data = await fetchBans(timeRange, page, PAGE_SIZE); + const data = await fetchBans(timeRange, page, PAGE_SIZE, origin); setBanItems(data.items); setTotal(data.total); } catch (err: unknown) { @@ -63,7 +67,7 @@ export function useBans(timeRange: TimeRange): UseBansResult { } finally { setLoading(false); } - }, [timeRange, page]); + }, [timeRange, page, origin]); // Stable ref to the latest doFetch so the refresh callback is always current. const doFetchRef = useRef(doFetch); diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 4c595b4..c8421a4 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchBansByCountry } from "../api/map"; import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map"; +import type { BanOriginFilter } from "../types/ban"; // --------------------------------------------------------------------------- // Return type @@ -31,7 +32,10 @@ export interface UseMapDataResult { // Hook // --------------------------------------------------------------------------- -export function useMapData(range: TimeRange = "24h"): UseMapDataResult { +export function useMapData( + range: TimeRange = "24h", + origin: BanOriginFilter = "all", +): UseMapDataResult { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -43,7 +47,7 @@ export function useMapData(range: TimeRange = "24h"): UseMapDataResult { setLoading(true); setError(null); - fetchBansByCountry(range) + fetchBansByCountry(range, origin) .then((resp) => { setData(resp); }) @@ -55,7 +59,7 @@ export function useMapData(range: TimeRange = "24h"): UseMapDataResult { .finally((): void => { setLoading(false); }); - }, [range]); + }, [range, origin]); useEffect((): (() => void) => { load(); diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index fbe4744..8b24382 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -5,6 +5,7 @@ * Jails — per-jail config accordion with inline editing * Global — global fail2ban settings (log level, DB config) * Server — server-level settings + flush logs + * Map — map color threshold configuration * Regex Tester — live pattern tester */ @@ -44,10 +45,15 @@ import { useRegexTester, useServerSettings, } from "../hooks/useConfig"; +import { + fetchMapColorThresholds, + updateMapColorThresholds, +} from "../api/config"; import type { GlobalConfigUpdate, JailConfig, JailConfigUpdate, + MapColorThresholdsUpdate, ServerSettingsUpdate, } 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("100"); + const [thresholdMedium, setThresholdMedium] = useState("50"); + const [thresholdLow, setThresholdLow] = useState("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 => { + 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 => { + 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 ; + } + + return ( +
+ + Map Color Thresholds + + + 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. + + + {message && ( + + {message.text} + + )} + +
+
+ + { + setThresholdLow(d.value); + }} + min={1} + /> + + + { + setThresholdMedium(d.value); + }} + min={1} + /> + + + { + setThresholdHigh(d.value); + }} + min={1} + /> + +
+ + + • 1 to {thresholdLow}: Light green → Full green
+ • {thresholdLow} to {thresholdMedium}: Green → Yellow
+ • {thresholdMedium} to {thresholdHigh}: Yellow → Red
+ • {thresholdHigh}+: Solid red +
+ +
+ +
+
+
+ ); +} + // --------------------------------------------------------------------------- // RegexTesterTab // --------------------------------------------------------------------------- @@ -958,7 +1114,7 @@ function RegexTesterTab(): React.JSX.Element { // ConfigPage (root) // --------------------------------------------------------------------------- -type TabValue = "jails" | "global" | "server" | "regex"; +type TabValue = "jails" | "global" | "server" | "map" | "regex"; export function ConfigPage(): React.JSX.Element { const styles = useStyles(); @@ -985,6 +1141,7 @@ export function ConfigPage(): React.JSX.Element { Jails Global Server + Map Regex Tester @@ -992,6 +1149,7 @@ export function ConfigPage(): React.JSX.Element { {tab === "jails" && } {tab === "global" && } {tab === "server" && } + {tab === "map" && } {tab === "regex" && } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a11cbaa..b3ca399 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -16,8 +16,8 @@ import { } from "@fluentui/react-components"; import { BanTable } from "../components/BanTable"; import { ServerStatusBar } from "../components/ServerStatusBar"; -import type { TimeRange } from "../types/ban"; -import { TIME_RANGE_LABELS } from "../types/ban"; +import type { BanOriginFilter, TimeRange } 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. */ const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"]; +/** Ordered origin filter options for the toolbar. */ +const ORIGIN_FILTERS: BanOriginFilter[] = ["all", "blocklist", "selfblock"]; + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -86,6 +89,7 @@ const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"]; export function DashboardPage(): React.JSX.Element { const styles = useStyles(); const [timeRange, setTimeRange] = useState("24h"); + const [originFilter, setOriginFilter] = useState("all"); return (
@@ -119,11 +123,28 @@ export function DashboardPage(): React.JSX.Element { ))} + + {/* Origin filter */} + + {ORIGIN_FILTERS.map((f) => ( + { + setOriginFilter(f); + }} + aria-pressed={originFilter === f} + > + {BAN_ORIGIN_FILTER_LABELS[f]} + + ))} +
{/* Ban table */}
- +
diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index bd13323..731f564 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -6,8 +6,9 @@ * bans when no country is selected). */ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { + Badge, Button, MessageBar, MessageBarBody, @@ -29,7 +30,10 @@ import { import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons"; import { WorldMap } from "../components/WorldMap"; import { useMapData } from "../hooks/useMapData"; +import { fetchMapColorThresholds } from "../api/config"; import type { TimeRange } from "../types/map"; +import type { BanOriginFilter } from "../types/ban"; +import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban"; // --------------------------------------------------------------------------- // Styles @@ -86,10 +90,30 @@ const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [ export function MapPage(): React.JSX.Element { const styles = useStyles(); const [range, setRange] = useState("24h"); + const [originFilter, setOriginFilter] = useState("all"); const [selectedCountry, setSelectedCountry] = useState(null); + const [thresholdLow, setThresholdLow] = useState(20); + const [thresholdMedium, setThresholdMedium] = useState(50); + const [thresholdHigh, setThresholdHigh] = useState(100); const { countries, countryNames, bans, total, loading, error, refresh } = - useMapData(range); + useMapData(range, originFilter); + + // Fetch color thresholds on mount + useEffect(() => { + const loadThresholds = async (): Promise => { + 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). */ const visibleBans = useMemo(() => { @@ -128,6 +152,23 @@ export function MapPage(): React.JSX.Element { ))} + {/* Origin filter */} + + } onClick={(): void => { @@ -162,6 +203,9 @@ export function MapPage(): React.JSX.Element { countries={countries} selectedCountry={selectedCountry} onSelectCountry={setSelectedCountry} + thresholdLow={thresholdLow} + thresholdMedium={thresholdMedium} + thresholdHigh={thresholdHigh} /> )} @@ -211,13 +255,14 @@ export function MapPage(): React.JSX.Element { Jail Banned At Country + Origin Times Banned {visibleBans.length === 0 ? ( - + No bans found. @@ -244,6 +289,16 @@ export function MapPage(): React.JSX.Element { {ban.country_name ?? ban.country_code ?? "—"} + + + + {ban.origin === "blocklist" ? "Blocklist" : "Selfblock"} + + + {String(ban.ban_count)} diff --git a/frontend/src/types/ban.ts b/frontend/src/types/ban.ts index 6d03b9d..17593b6 100644 --- a/frontend/src/types/ban.ts +++ b/frontend/src/types/ban.ts @@ -11,6 +11,22 @@ /** The four supported time-range presets for dashboard views. */ 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 = { + all: "All", + blocklist: "Blocklist", + selfblock: "Selfblock", +} as const; + /** Human-readable labels for each time-range preset. */ export const TIME_RANGE_LABELS: Record = { "24h": "Last 24 h", @@ -47,6 +63,8 @@ export interface DashboardBanItem { org: string | null; /** How many times this IP was banned. */ ban_count: number; + /** Whether this ban came from a blocklist import or fail2ban itself. */ + origin: "blocklist" | "selfblock"; } /** diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 9f5582c..361cedc 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -128,3 +128,19 @@ export interface AddLogPathRequest { log_path: string; 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; +} diff --git a/frontend/src/types/map.ts b/frontend/src/types/map.ts index 3b4ce84..db541d5 100644 --- a/frontend/src/types/map.ts +++ b/frontend/src/types/map.ts @@ -16,6 +16,8 @@ export interface MapBanItem { asn: string | null; org: string | null; 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 */ diff --git a/frontend/src/utils/mapColors.ts b/frontend/src/utils/mapColors.ts new file mode 100644 index 0000000..8513a6b --- /dev/null +++ b/frontend/src/utils/mapColors.ts @@ -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); +}