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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user