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:
2026-03-11 17:01:19 +01:00
parent df0528b2c2
commit fe8eefa173
13 changed files with 799 additions and 6 deletions

View File

@@ -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