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
This commit is contained in:
@@ -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
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
@@ -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 `<JailDistributionChart timeRange={timeRange}
|
||||
origin={originFilter} />`, 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.
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<BanTrendResponse>(`${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<BansByJailResponse> {
|
||||
const params = new URLSearchParams({ range });
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
return get<BansByJailResponse>(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
218
frontend/src/components/JailDistributionChart.tsx
Normal file
218
frontend/src/components/JailDistributionChart.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
|
||||
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
|
||||
borderRadius: "4px",
|
||||
padding: "8px 12px",
|
||||
color: resolveFluentToken(tokens.colorNeutralForeground1),
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
<strong>{fullName}</strong>
|
||||
<br />
|
||||
{String(value)} ban{value === 1 ? "" : "s"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.stateWrapper}>
|
||||
<Spinner label="Loading chart data…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (jails.length === 0) {
|
||||
return (
|
||||
<div className={styles.stateWrapper}>
|
||||
<Text className={styles.emptyText}>No ban data for the selected period.</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.wrapper} style={{ height: chartHeight }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
layout="vertical"
|
||||
data={entries}
|
||||
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: axisColour, fontSize: 12 }}
|
||||
axisLine={{ stroke: gridColour }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={160}
|
||||
tick={{ fill: axisColour, fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={JailTooltip} cursor={{ fill: "transparent" }} />
|
||||
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend/src/hooks/useJailDistribution.ts
Normal file
85
frontend/src/hooks/useJailDistribution.ts
Normal file
@@ -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<JailBanCount[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
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 };
|
||||
}
|
||||
@@ -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 {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Jail Distribution section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Jail Distribution
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.tabContent}>
|
||||
<JailDistributionChart timeRange={timeRange} origin={originFilter} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Ban list section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user