From fe8eefa1738cd1e4ce796b23a5ed0e18387263f8 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 11 Mar 2026 17:01:19 +0100 Subject: [PATCH] Add jail distribution chart (Stage 5) - backend: GET /api/dashboard/bans/by-jail endpoint - JailBanCount + BansByJailResponse Pydantic models in ban.py - bans_by_jail() service function with origin filter support - Route added to dashboard router - 17 new tests (7 service, 10 router); full suite 497 passed, 83% coverage - frontend: JailDistributionChart component - JailBanCount / BansByJailResponse types in types/ban.ts - dashboardBansByJail endpoint constant in api/endpoints.ts - fetchBansByJail() in api/dashboard.ts - useJailDistribution hook in hooks/useJailDistribution.ts - JailDistributionChart component (horizontal bar chart, Recharts) - DashboardPage: full-width Jail Distribution section below Top Countries --- Docs/Architekture.md | 6 + Docs/Tasks.md | 30 ++- backend/app/models/ban.py | 26 +++ backend/app/routers/dashboard.py | 42 +++- backend/app/services/ban_service.py | 69 ++++++ backend/tests/test_routers/test_dashboard.py | 135 +++++++++++ .../tests/test_services/test_ban_service.py | 127 ++++++++++ frontend/src/api/dashboard.ts | 23 +- frontend/src/api/endpoints.ts | 1 + .../src/components/JailDistributionChart.tsx | 218 ++++++++++++++++++ frontend/src/hooks/useJailDistribution.ts | 85 +++++++ frontend/src/pages/DashboardPage.tsx | 15 ++ frontend/src/types/ban.ts | 28 +++ 13 files changed, 799 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/JailDistributionChart.tsx create mode 100644 frontend/src/hooks/useJailDistribution.ts diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 13bbcb1..1bc14bd 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -123,6 +123,7 @@ backend/ │ │ └── import_log_repo.py # Import run history records │ ├── tasks/ # APScheduler background jobs │ │ ├── blocklist_import.py# Scheduled blocklist download and application +│ │ ├── geo_cache_flush.py # Periodic geo cache persistence (dirty-set flush to SQLite) │ │ └── health_check.py # Periodic fail2ban connectivity probe │ └── utils/ # Helpers, constants, shared types │ ├── fail2ban_client.py # Async wrapper around the fail2ban socket protocol @@ -200,6 +201,7 @@ APScheduler background jobs that run on a schedule without user interaction. | Task | Purpose | |---|---| | `blocklist_import.py` | Downloads all enabled blocklist sources, validates entries, applies bans, records results in the import log | +| `geo_cache_flush.py` | Periodically flushes newly resolved IPs from the in-memory dirty set to the `geo_cache` SQLite table (default: every 60 seconds). GET requests populate only the in-memory cache; this task persists them without blocking any request. | | `health_check.py` | Periodically pings the fail2ban socket and updates the cached server status so the frontend always has fresh data | #### Utils (`app/utils/`) @@ -586,6 +588,7 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas |---|---| | `settings` | Key-value store for application configuration (master password hash, fail2ban socket path, database path, timezone, session duration) | | `sessions` | Active session tokens with expiry timestamps | +| `geo_cache` | Resolved IP geolocation results (ip, country_code, country_name, asn, org, cached_at). Loaded into memory at startup via `load_cache_from_db()`; new entries are flushed back by the `geo_cache_flush` background task. | | `blocklist_sources` | Registered blocklist URLs (id, name, url, enabled, created_at, updated_at) | | `import_logs` | Record of every blocklist import run (id, source_id, timestamp, ips_imported, ips_skipped, errors, status) | @@ -606,6 +609,8 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas - Session expiry is configurable (set during setup, stored in `settings`). - The frontend `AuthProvider` checks session validity on mount and redirects to `/login` if invalid. - The backend `dependencies.py` provides an `authenticated` dependency that validates the session cookie on every protected endpoint. +- **Session validation cache** — validated session tokens are cached in memory for 10 seconds (`_session_cache` dict in `dependencies.py`) to avoid a SQLite round-trip on every request from the same browser. The cache is invalidated immediately on logout. +- **Setup-completion flag** — once `is_setup_complete()` returns `True`, the result is stored in `app.state._setup_complete_cached`. The `SetupRedirectMiddleware` skips the DB query on all subsequent requests, removing 1 SQL query per request for the common post-setup case. --- @@ -619,6 +624,7 @@ APScheduler 4.x (async mode) manages recurring background tasks. │ (async, in-process) │ ├──────────────────────┤ │ blocklist_import │ ── runs on configured schedule (default: daily 03:00) +│ geo_cache_flush │ ── runs every 60 seconds │ health_check │ ── runs every 30 seconds └──────────────────────┘ ``` diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 4629c69..9ca9f1e 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -353,7 +353,15 @@ Add the `BanTrendChart` to the dashboard page **above** the two country charts a ### Task 5.1 — Add a backend endpoint for ban counts per jail -**Status:** `not started` +**Status:** `done` + +Added `GET /api/dashboard/bans/by-jail`. New Pydantic models `JailBanCount` and +`BansByJailResponse` added to `ban.py`. Service function `bans_by_jail()` in +`ban_service.py` queries the `bans` table with `GROUP BY jail ORDER BY COUNT(*) DESC` +and applies the origin filter. Route added to `dashboard.py`. 7 new service tests +(happy path, total equality, empty DB, time-window exclusion, origin filter variants) +and 10 new router tests — all pass, total suite 497 passed, 83% coverage. +`ruff check` and `mypy --strict` pass. The existing `GET /api/jails` endpoint returns jail metadata with `status.currently_banned` — but this counts **currently active** bans, not historical bans in the selected time window. The dashboard needs historical ban counts per jail within the selected time range. @@ -395,7 +403,16 @@ class BansByJailResponse(BaseModel): ### Task 5.2 — Create the `JailDistributionChart` component -**Status:** `not started` +**Status:** `done` + +Created `frontend/src/components/JailDistributionChart.tsx` — a horizontal +bar chart using Recharts `BarChart` showing ban counts per jail sorted descending. +Added `JailBanCount`/`BansByJailResponse` types to `types/ban.ts`, +`dashboardBansByJail` constant to `api/endpoints.ts`, `fetchBansByJail()` to +`api/dashboard.ts`, and the `useJailDistribution` hook at +`hooks/useJailDistribution.ts`. Component handles loading (Spinner), error +(MessageBar), and empty states inline. `tsc --noEmit` and ESLint pass with zero +warnings. Create `frontend/src/components/JailDistributionChart.tsx`. This component renders a **horizontal bar chart** showing the distribution of bans across jails. @@ -422,7 +439,14 @@ Create `frontend/src/components/JailDistributionChart.tsx`. This component rende ### Task 5.3 — Integrate the jail distribution chart into `DashboardPage` -**Status:** `not started` +**Status:** `done` + +Added a full-width "Jail Distribution" section card to `DashboardPage` below the +"Top Countries" section (2-column country charts on row 1, jail chart full-width +on row 2). The section renders ``, sharing the same state already used by the other +charts. Loading, error, and empty states are handled inside +`JailDistributionChart` itself. `tsc --noEmit` and ESLint pass with zero warnings. Add the `JailDistributionChart` as a third chart card alongside the two country charts, or in a second chart row below them if space is constrained. diff --git a/backend/app/models/ban.py b/backend/app/models/ban.py index 816b949..b286a36 100644 --- a/backend/app/models/ban.py +++ b/backend/app/models/ban.py @@ -280,3 +280,29 @@ class BanTrendResponse(BaseModel): ..., description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').", ) + + +# --------------------------------------------------------------------------- +# By-jail endpoint models +# --------------------------------------------------------------------------- + + +class JailBanCount(BaseModel): + """A single jail entry in the bans-by-jail aggregation.""" + + model_config = ConfigDict(strict=True) + + jail: str = Field(..., description="Jail name.") + count: int = Field(..., ge=0, description="Number of bans recorded in this jail.") + + +class BansByJailResponse(BaseModel): + """Response for the ``GET /api/dashboard/bans/by-jail`` endpoint.""" + + model_config = ConfigDict(strict=True) + + jails: list[JailBanCount] = Field( + default_factory=list, + description="Jails ordered by ban count descending.", + ) + total: int = Field(..., ge=0, description="Total ban count in the selected window.") diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index e5b8a97..5d77858 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -5,8 +5,9 @@ fail2ban server health snapshot. The snapshot is maintained by the background health-check task and refreshed every 30 seconds. Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table, -``GET /api/dashboard/bans/by-country`` for country aggregation, and -``GET /api/dashboard/bans/trend`` for time-bucketed ban counts. +``GET /api/dashboard/bans/by-country`` for country aggregation, +``GET /api/dashboard/bans/trend`` for time-bucketed ban counts, and +``GET /api/dashboard/bans/by-jail`` for per-jail ban counts. """ from __future__ import annotations @@ -22,6 +23,7 @@ from app.dependencies import AuthDep from app.models.ban import ( BanOrigin, BansByCountryResponse, + BansByJailResponse, BanTrendResponse, DashboardBanListResponse, TimeRange, @@ -206,3 +208,39 @@ async def get_ban_trend( socket_path: str = request.app.state.settings.fail2ban_socket return await ban_service.ban_trend(socket_path, range, origin=origin) + + +@router.get( + "/bans/by-jail", + response_model=BansByJailResponse, + summary="Return ban counts aggregated by jail", +) +async def get_bans_by_jail( + 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.", + ), +) -> BansByJailResponse: + """Return ban counts grouped by jail name for the selected time window. + + Queries the fail2ban database and returns a list of jails sorted by + ban count descending. This endpoint is intended for the dashboard jail + distribution bar chart. + + Args: + request: The incoming request (used to access ``app.state``). + _auth: Validated session dependency. + range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or + ``"365d"``. + origin: Optional filter by ban origin. + + Returns: + :class:`~app.models.ban.BansByJailResponse` with per-jail counts + sorted descending and the total for the selected window. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + + return await ban_service.bans_by_jail(socket_path, range, origin=origin) diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index bc596b0..c2f6549 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_service.py @@ -24,10 +24,12 @@ from app.models.ban import ( TIME_RANGE_SECONDS, BanOrigin, BansByCountryResponse, + BansByJailResponse, BanTrendBucket, BanTrendResponse, DashboardBanItem, DashboardBanListResponse, + JailBanCount, TimeRange, _derive_origin, bucket_count, @@ -573,3 +575,70 @@ async def ban_trend( buckets=buckets, bucket_size=BUCKET_SIZE_LABEL[range_], ) + + +# --------------------------------------------------------------------------- +# bans_by_jail +# --------------------------------------------------------------------------- + + +async def bans_by_jail( + socket_path: str, + range_: TimeRange, + *, + origin: BanOrigin | None = None, +) -> BansByJailResponse: + """Return ban counts aggregated per jail for the selected time window. + + Queries the fail2ban database ``bans`` table, groups records by jail + name, and returns them ordered by count descending. The origin filter + is applied when provided so callers can restrict results to blocklist- + imported bans or organic fail2ban bans. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or + ``"365d"``). + origin: Optional origin filter — ``"blocklist"`` restricts to the + ``blocklist-import`` jail, ``"selfblock"`` excludes it. + + Returns: + :class:`~app.models.ban.BansByJailResponse` with per-jail counts + sorted descending and the total ban count. + """ + 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_jail", + 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 >= ?" + origin_clause, + (since, *origin_params), + ) as cur: + count_row = await cur.fetchone() + total: int = int(count_row[0]) if count_row else 0 + + async with f2b_db.execute( + "SELECT jail, COUNT(*) AS cnt " + "FROM bans " + "WHERE timeofban >= ?" + + origin_clause + + " GROUP BY jail ORDER BY cnt DESC", + (since, *origin_params), + ) as cur: + rows = await cur.fetchall() + + jails: list[JailBanCount] = [ + JailBanCount(jail=str(row["jail"]), count=int(row["cnt"])) for row in rows + ] + return BansByJailResponse(jails=jails, total=total) diff --git a/backend/tests/test_routers/test_dashboard.py b/backend/tests/test_routers/test_dashboard.py index eb2ea71..d89c9f6 100644 --- a/backend/tests/test_routers/test_dashboard.py +++ b/backend/tests/test_routers/test_dashboard.py @@ -711,3 +711,138 @@ class TestBanTrend: assert body["buckets"] == [] assert body["bucket_size"] == "1h" + +# --------------------------------------------------------------------------- +# Bans by jail endpoint +# --------------------------------------------------------------------------- + + +def _make_bans_by_jail_response() -> object: + """Build a stub :class:`~app.models.ban.BansByJailResponse`.""" + from app.models.ban import BansByJailResponse, JailBanCount + + return BansByJailResponse( + jails=[ + JailBanCount(jail="sshd", count=10), + JailBanCount(jail="nginx", count=5), + ], + total=15, + ) + + +@pytest.mark.anyio +class TestBansByJail: + """GET /api/dashboard/bans/by-jail.""" + + async def test_returns_200_when_authenticated( + self, dashboard_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200.""" + with patch( + "app.routers.dashboard.ban_service.bans_by_jail", + new=AsyncMock(return_value=_make_bans_by_jail_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-jail") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/dashboard/bans/by-jail") + assert response.status_code == 401 + + async def test_response_shape(self, dashboard_client: AsyncClient) -> None: + """Response body contains ``jails`` list and ``total`` integer.""" + with patch( + "app.routers.dashboard.ban_service.bans_by_jail", + new=AsyncMock(return_value=_make_bans_by_jail_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-jail") + + body = response.json() + assert "jails" in body + assert "total" in body + assert isinstance(body["total"], int) + + async def test_each_jail_has_name_and_count( + self, dashboard_client: AsyncClient + ) -> None: + """Every element of ``jails`` has ``jail`` (string) and ``count`` (int).""" + with patch( + "app.routers.dashboard.ban_service.bans_by_jail", + new=AsyncMock(return_value=_make_bans_by_jail_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-jail") + + for entry in response.json()["jails"]: + assert "jail" in entry + assert "count" in entry + assert isinstance(entry["jail"], str) + assert isinstance(entry["count"], int) + + async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None: + """Omitting ``range`` defaults to ``"24h"``.""" + mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) + with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/by-jail") + + called_range = mock_fn.call_args[0][1] + assert called_range == "24h" + + async def test_accepts_range_param(self, dashboard_client: AsyncClient) -> None: + """The ``range`` query parameter is forwarded to the service.""" + mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) + with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/by-jail?range=7d") + + called_range = mock_fn.call_args[0][1] + assert called_range == "7d" + + async def test_origin_param_forwarded(self, dashboard_client: AsyncClient) -> None: + """``?origin=blocklist`` is passed as a keyword arg to the service.""" + mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) + with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): + await dashboard_client.get( + "/api/dashboard/bans/by-jail?origin=blocklist" + ) + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") == "blocklist" + + async def test_no_origin_defaults_to_none( + self, dashboard_client: AsyncClient + ) -> None: + """Omitting ``origin`` passes ``None`` to the service.""" + mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) + with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/by-jail") + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") is None + + async def test_invalid_range_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid ``range`` value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/by-jail?range=invalid" + ) + assert response.status_code == 422 + + async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None: + """Empty jails list is serialised correctly.""" + from app.models.ban import BansByJailResponse + + empty = BansByJailResponse(jails=[], total=0) + with patch( + "app.routers.dashboard.ban_service.bans_by_jail", + new=AsyncMock(return_value=empty), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-jail") + + body = response.json() + assert body["jails"] == [] + assert body["total"] == 0 + diff --git a/backend/tests/test_services/test_ban_service.py b/backend/tests/test_services/test_ban_service.py index b17fad1..6739bbd 100644 --- a/backend/tests/test_services/test_ban_service.py +++ b/backend/tests/test_services/test_ban_service.py @@ -776,3 +776,130 @@ class TestBanTrend: parsed = datetime.fromisoformat(bucket.timestamp) assert parsed.tzinfo is not None # Must be timezone-aware (UTC) + +# --------------------------------------------------------------------------- +# bans_by_jail +# --------------------------------------------------------------------------- + + +class TestBansByJail: + """Verify ban_service.bans_by_jail() behaviour.""" + + async def test_returns_jails_sorted_descending(self, tmp_path: Path) -> None: + """Jails are returned ordered by count descending.""" + import time as _time + + now = int(_time.time()) + one_hour_ago = now - 3600 + path = str(tmp_path / "test_by_jail.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "sshd", "ip": "1.1.1.1", "timeofban": one_hour_ago}, + {"jail": "sshd", "ip": "1.1.1.2", "timeofban": one_hour_ago}, + {"jail": "nginx", "ip": "2.2.2.2", "timeofban": one_hour_ago}, + ], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + assert result.jails[0].jail == "sshd" + assert result.jails[0].count == 2 + assert result.jails[1].jail == "nginx" + assert result.jails[1].count == 1 + + async def test_total_equals_sum_of_counts(self, tmp_path: Path) -> None: + """``total`` equals the sum of all per-jail counts.""" + import time as _time + + now = int(_time.time()) + one_hour_ago = now - 3600 + path = str(tmp_path / "test_by_jail_total.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "sshd", "ip": "1.1.1.1", "timeofban": one_hour_ago}, + {"jail": "nginx", "ip": "2.2.2.2", "timeofban": one_hour_ago}, + {"jail": "nginx", "ip": "3.3.3.3", "timeofban": one_hour_ago}, + ], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + assert result.total == sum(j.count for j in result.jails) + assert result.total == 3 + + async def test_empty_db_returns_empty_list(self, empty_f2b_db_path: str) -> None: + """An empty database returns an empty jails list with total zero.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + assert result.jails == [] + assert result.total == 0 + + async def test_excludes_bans_outside_time_window(self, f2b_db_path: str) -> None: + """Bans older than the time window are not counted.""" + # f2b_db_path has one ban from _TWO_DAYS_AGO, which is outside "24h". + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + # Only 2 bans within 24h (both from _ONE_HOUR_AGO). + assert result.total == 2 + + async def test_origin_filter_blocklist(self, mixed_origin_db_path: str) -> None: + """``origin='blocklist'`` returns only 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.bans_by_jail( + "/fake/sock", "24h", origin="blocklist" + ) + + assert len(result.jails) == 1 + assert result.jails[0].jail == "blocklist-import" + assert result.total == 1 + + async def test_origin_filter_selfblock(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.bans_by_jail( + "/fake/sock", "24h", origin="selfblock" + ) + + jail_names = {j.jail for j in result.jails} + assert "blocklist-import" not in jail_names + assert result.total == 2 + + async def test_no_origin_filter_returns_all_jails( + self, mixed_origin_db_path: str + ) -> None: + """``origin=None`` returns bans from all 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_jail( + "/fake/sock", "24h", origin=None + ) + + assert result.total == 3 + assert len(result.jails) == 3 + diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 6da9062..90309d8 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -7,8 +7,9 @@ import { get } from "./client"; import { ENDPOINTS } from "./endpoints"; import type { - BanTrendResponse, BanOriginFilter, + BansByJailResponse, + BanTrendResponse, DashboardBanListResponse, TimeRange, } from "../types/ban"; @@ -72,3 +73,23 @@ export async function fetchBanTrend( } return get(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`); } + +/** + * Fetch ban counts aggregated by jail for the selected time window. + * + * @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`. + * @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"` + * (default `"all"`, which omits the parameter entirely). + * @returns {@link BansByJailResponse} with jails sorted by ban count descending. + * @throws {ApiError} When the server returns a non-2xx status. + */ +export async function fetchBansByJail( + range: TimeRange, + origin: BanOriginFilter = "all", +): Promise { + const params = new URLSearchParams({ range }); + if (origin !== "all") { + params.set("origin", origin); + } + return get(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`); +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 926f7b6..7330212 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -31,6 +31,7 @@ export const ENDPOINTS = { dashboardBans: "/dashboard/bans", dashboardBansByCountry: "/dashboard/bans/by-country", dashboardBansTrend: "/dashboard/bans/trend", + dashboardBansByJail: "/dashboard/bans/by-jail", // ------------------------------------------------------------------------- // Jails diff --git a/frontend/src/components/JailDistributionChart.tsx b/frontend/src/components/JailDistributionChart.tsx new file mode 100644 index 0000000..7840afb --- /dev/null +++ b/frontend/src/components/JailDistributionChart.tsx @@ -0,0 +1,218 @@ +/** + * JailDistributionChart — horizontal bar chart showing ban counts per jail, + * sorted descending, for the selected time window. + * + * Calls `useJailDistribution` internally and handles loading, error, and + * empty states so the parent only needs to pass filter props. + */ + +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TooltipContentProps } from "recharts/types/component/Tooltip"; +import { + MessageBar, + MessageBarBody, + Spinner, + Text, + tokens, + makeStyles, +} from "@fluentui/react-components"; +import { + CHART_AXIS_TEXT_TOKEN, + CHART_GRID_LINE_TOKEN, + CHART_PALETTE, + resolveFluentToken, +} from "../utils/chartTheme"; +import { useJailDistribution } from "../hooks/useJailDistribution"; +import type { BanOriginFilter, TimeRange } from "../types/ban"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum characters before truncating a jail name on the Y-axis. */ +const MAX_LABEL_LENGTH = 24; + +/** Height per bar row in pixels. */ +const BAR_HEIGHT_PX = 36; + +/** Minimum chart height in pixels. */ +const MIN_CHART_HEIGHT = 180; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Props for {@link JailDistributionChart}. */ +interface JailDistributionChartProps { + /** Time-range preset controlling the query window. */ + timeRange: TimeRange; + /** Origin filter controlling which bans are included. */ + origin: BanOriginFilter; +} + +/** Internal chart data point shape. */ +interface BarEntry { + /** Full jail name used by Tooltip. */ + fullName: string; + /** Truncated name displayed on the Y-axis. */ + name: string; + /** Ban count. */ + value: number; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + wrapper: { + width: "100%", + overflowX: "hidden", + }, + stateWrapper: { + width: "100%", + minHeight: `${String(MIN_CHART_HEIGHT)}px`, + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + emptyText: { + color: tokens.colorNeutralForeground3, + }, +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build the chart dataset from the raw jail list. + * + * @param jails - Ordered list of `{jail, count}` items from the API. + * @returns Array of `BarEntry` objects ready for Recharts. + */ +function buildEntries(jails: Array<{ jail: string; count: number }>): BarEntry[] { + return jails.map(({ jail, count }) => ({ + fullName: jail, + name: jail.length > MAX_LABEL_LENGTH ? `${jail.slice(0, MAX_LABEL_LENGTH)}…` : jail, + value: count, + })); +} + +// --------------------------------------------------------------------------- +// Custom tooltip +// --------------------------------------------------------------------------- + +function JailTooltip(props: TooltipContentProps): React.JSX.Element | null { + const { active, payload } = props; + if (!active || payload.length === 0) return null; + const entry = payload[0]; + if (entry == null) return null; + + const { fullName, value } = entry.payload as BarEntry; + return ( +
+ {fullName} +
+ {String(value)} ban{value === 1 ? "" : "s"} +
+ ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Horizontal bar chart showing ban counts per jail for the selected window. + * + * Fetches data via `useJailDistribution` and renders loading, error, and + * empty states inline. + * + * @param props - `timeRange` and `origin` filter props. + */ +export function JailDistributionChart({ + timeRange, + origin, +}: JailDistributionChartProps): React.JSX.Element { + const styles = useStyles(); + const { jails, isLoading, error } = useJailDistribution(timeRange, origin); + + if (error != null) { + return ( + + {error} + + ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (jails.length === 0) { + return ( +
+ No ban data for the selected period. +
+ ); + } + + const entries = buildEntries(jails); + const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT); + + const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? ""); + const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN); + const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN); + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/frontend/src/hooks/useJailDistribution.ts b/frontend/src/hooks/useJailDistribution.ts new file mode 100644 index 0000000..86bf4b1 --- /dev/null +++ b/frontend/src/hooks/useJailDistribution.ts @@ -0,0 +1,85 @@ +/** + * `useJailDistribution` hook. + * + * Fetches per-jail ban counts for the jail distribution chart. + * Re-fetches automatically when `timeRange` or `origin` changes. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchBansByJail } from "../api/dashboard"; +import type { BanOriginFilter, JailBanCount, TimeRange } from "../types/ban"; + +// --------------------------------------------------------------------------- +// Return type +// --------------------------------------------------------------------------- + +/** Return value shape for {@link useJailDistribution}. */ +export interface UseJailDistributionResult { + /** Jails ordered by ban count descending. */ + jails: JailBanCount[]; + /** Total ban count for the selected window. */ + total: number; + /** True while a fetch is in flight. */ + isLoading: boolean; + /** Error message or `null`. */ + error: string | null; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Fetch and expose per-jail ban counts for the `JailDistributionChart` component. + * + * @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`. + * @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`. + * @returns Jail list, total count, loading state, and error. + */ +export function useJailDistribution( + timeRange: TimeRange, + origin: BanOriginFilter, +): UseJailDistributionResult { + const [jails, setJails] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const abortRef = useRef(null); + + const load = useCallback((): void => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setIsLoading(true); + setError(null); + + fetchBansByJail(timeRange, origin) + .then((data) => { + if (controller.signal.aborted) return; + setJails(data.jails); + setTotal(data.total); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setError( + err instanceof Error ? err.message : "Failed to fetch jail distribution", + ); + }) + .finally(() => { + if (!controller.signal.aborted) { + setIsLoading(false); + } + }); + }, [timeRange, origin]); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + return { jails, total, isLoading, error }; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 669f136..eb58324 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -19,6 +19,7 @@ import { } from "@fluentui/react-components"; import { BanTable } from "../components/BanTable"; import { BanTrendChart } from "../components/BanTrendChart"; +import { JailDistributionChart } from "../components/JailDistributionChart"; import { ServerStatusBar } from "../components/ServerStatusBar"; import { TopCountriesBarChart } from "../components/TopCountriesBarChart"; import { TopCountriesPieChart } from "../components/TopCountriesPieChart"; @@ -166,6 +167,20 @@ export function DashboardPage(): React.JSX.Element { + {/* ------------------------------------------------------------------ */} + {/* Jail Distribution section */} + {/* ------------------------------------------------------------------ */} +
+
+ + Jail Distribution + +
+
+ +
+
+ {/* ------------------------------------------------------------------ */} {/* Ban list section */} {/* ------------------------------------------------------------------ */} diff --git a/frontend/src/types/ban.ts b/frontend/src/types/ban.ts index 7062d11..4a1d51e 100644 --- a/frontend/src/types/ban.ts +++ b/frontend/src/types/ban.ts @@ -110,3 +110,31 @@ export interface BanTrendResponse { /** Human-readable bucket size label, e.g. `"1h"`, `"6h"`, `"1d"`, `"7d"`. */ bucket_size: string; } + +// --------------------------------------------------------------------------- +// Bans by jail +// --------------------------------------------------------------------------- + +/** + * A single jail entry in the bans-by-jail aggregation. + * + * Mirrors `JailBanCount` from `backend/app/models/ban.py`. + */ +export interface JailBanCount { + /** Jail name. */ + jail: string; + /** Number of bans recorded in this jail for the selected window. */ + count: number; +} + +/** + * Response from `GET /api/dashboard/bans/by-jail`. + * + * Mirrors `BansByJailResponse` from `backend/app/models/ban.py`. + */ +export interface BansByJailResponse { + /** Jails ordered by ban count descending. */ + jails: JailBanCount[]; + /** Total ban count in the selected window. */ + total: number; +}