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