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

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