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

View File

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