Add GET /api/dashboard/bans/trend endpoint

Implement time-bucketed ban aggregation for dashboard trend charts:

- Add BanTrendBucket / BanTrendResponse Pydantic models and
  BUCKET_SECONDS / BUCKET_SIZE_LABEL / bucket_count helpers to ban.py
- Add ban_service.ban_trend(): queries fail2ban DB with SQL bucket
  grouping, fills zero-count buckets, respects origin filter
- Add GET /api/dashboard/bans/trend route in dashboard.py
- 20 new tests (10 service, 10 router); 480 total pass, 83% coverage
- ruff + mypy --strict clean
This commit is contained in:
2026-03-11 16:38:19 +01:00
parent 2ddfddfbbb
commit 9242b4709a
6 changed files with 511 additions and 4 deletions

View File

@@ -3,6 +3,7 @@
Request, response, and domain models used by the ban router and service.
"""
import math
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -220,3 +221,62 @@ class BansByCountryResponse(BaseModel):
description="All bans in the selected time window (up to the server limit).",
)
total: int = Field(..., ge=0, description="Total ban count in the window.")
# ---------------------------------------------------------------------------
# Trend endpoint models
# ---------------------------------------------------------------------------
#: Bucket size in seconds for each time-range preset.
BUCKET_SECONDS: dict[str, int] = {
"24h": 3_600, # 1 hour → 24 buckets
"7d": 6 * 3_600, # 6 hours → 28 buckets
"30d": 86_400, # 1 day → 30 buckets
"365d": 7 * 86_400, # 7 days → ~53 buckets
}
#: Human-readable bucket size label for each time-range preset.
BUCKET_SIZE_LABEL: dict[str, str] = {
"24h": "1h",
"7d": "6h",
"30d": "1d",
"365d": "7d",
}
def bucket_count(range_: TimeRange) -> int:
"""Return the number of buckets needed to cover *range_* completely.
Args:
range_: One of the supported time-range presets.
Returns:
Ceiling division of the range duration by the bucket size so that
the last bucket is included even when the window is not an exact
multiple of the bucket size.
"""
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])
class BanTrendBucket(BaseModel):
"""A single time bucket in the ban trend series."""
model_config = ConfigDict(strict=True)
timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.")
count: int = Field(..., ge=0, description="Number of bans that started in this bucket.")
class BanTrendResponse(BaseModel):
"""Response for the ``GET /api/dashboard/bans/trend`` endpoint."""
model_config = ConfigDict(strict=True)
buckets: list[BanTrendBucket] = Field(
default_factory=list,
description="Time-ordered list of ban-count buckets covering the full window.",
)
bucket_size: str = Field(
...,
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
)

View File

@@ -4,7 +4,9 @@ Provides the ``GET /api/dashboard/status`` endpoint that returns the cached
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.
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.
"""
from __future__ import annotations
@@ -20,6 +22,7 @@ from app.dependencies import AuthDep
from app.models.ban import (
BanOrigin,
BansByCountryResponse,
BanTrendResponse,
DashboardBanListResponse,
TimeRange,
)
@@ -161,3 +164,45 @@ async def get_bans_by_country(
origin=origin,
)
@router.get(
"/bans/trend",
response_model=BanTrendResponse,
summary="Return ban counts aggregated into time buckets",
)
async def get_ban_trend(
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.",
),
) -> BanTrendResponse:
"""Return ban counts grouped into equal-width time buckets.
Each bucket represents a contiguous time interval within the selected
window. All buckets are returned — empty buckets (zero bans) are
included so the frontend always receives a complete, gap-free series
suitable for rendering a continuous area or line chart.
Bucket sizes:
* ``24h`` → 1-hour buckets (24 total)
* ``7d`` → 6-hour buckets (28 total)
* ``30d`` → 1-day buckets (30 total)
* ``365d`` → 7-day buckets (~53 total)
Args:
request: The incoming request (used to access ``app.state``).
_auth: Validated session dependency.
range: Time-range preset.
origin: Optional filter by ban origin.
Returns:
:class:`~app.models.ban.BanTrendResponse` with the ordered bucket
list and the bucket-size label.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
return await ban_service.ban_trend(socket_path, range, origin=origin)

View File

@@ -19,13 +19,18 @@ import structlog
from app.models.ban import (
BLOCKLIST_JAIL,
BUCKET_SECONDS,
BUCKET_SIZE_LABEL,
TIME_RANGE_SECONDS,
BanOrigin,
BansByCountryResponse,
BanTrendBucket,
BanTrendResponse,
DashboardBanItem,
DashboardBanListResponse,
TimeRange,
_derive_origin,
bucket_count,
)
from app.utils.fail2ban_client import Fail2BanClient
@@ -479,3 +484,92 @@ async def bans_by_country(
bans=bans,
total=total,
)
# ---------------------------------------------------------------------------
# ban_trend
# ---------------------------------------------------------------------------
async def ban_trend(
socket_path: str,
range_: TimeRange,
*,
origin: BanOrigin | None = None,
) -> BanTrendResponse:
"""Return ban counts aggregated into equal-width time buckets.
Queries the fail2ban database ``bans`` table and groups records by a
computed bucket index so the frontend can render a continuous time-series
chart. All buckets within the requested window are returned — buckets
that contain zero bans are included as zero-count entries so the
frontend always receives a complete, gap-free series.
Bucket sizes per time-range preset:
* ``24h`` → 1-hour buckets (24 total)
* ``7d`` → 6-hour buckets (28 total)
* ``30d`` → 1-day buckets (30 total)
* ``365d`` → 7-day buckets (~53 total)
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.BanTrendResponse` with a full bucket list
and the human-readable bucket-size label.
"""
since: int = _since_unix(range_)
bucket_secs: int = BUCKET_SECONDS[range_]
num_buckets: int = bucket_count(range_)
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info(
"ban_service_ban_trend",
db_path=db_path,
since=since,
range=range_,
origin=origin,
bucket_secs=bucket_secs,
num_buckets=num_buckets,
)
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 CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
"COUNT(*) AS cnt "
"FROM bans "
"WHERE timeofban >= ?"
+ origin_clause
+ " GROUP BY bucket_idx "
"ORDER BY bucket_idx",
(since, bucket_secs, since, *origin_params),
) as cur:
rows = await cur.fetchall()
# Map bucket_idx → count; ignore any out-of-range indices.
counts: dict[int, int] = {}
for row in rows:
idx: int = int(row["bucket_idx"])
if 0 <= idx < num_buckets:
counts[idx] = int(row["cnt"])
buckets: list[BanTrendBucket] = [
BanTrendBucket(
timestamp=_ts_to_iso(since + i * bucket_secs),
count=counts.get(i, 0),
)
for i in range(num_buckets)
]
return BanTrendResponse(
buckets=buckets,
bucket_size=BUCKET_SIZE_LABEL[range_],
)

View File

@@ -577,3 +577,137 @@ class TestOriginFilterParam:
_, kwargs = mock_fn.call_args
assert kwargs.get("origin") is None
# ---------------------------------------------------------------------------
# Ban trend endpoint
# ---------------------------------------------------------------------------
def _make_ban_trend_response(n_buckets: int = 24) -> object:
"""Build a stub :class:`~app.models.ban.BanTrendResponse`."""
from app.models.ban import BanTrendBucket, BanTrendResponse
buckets = [
BanTrendBucket(timestamp=f"2026-03-01T{i:02d}:00:00+00:00", count=i)
for i in range(n_buckets)
]
return BanTrendResponse(buckets=buckets, bucket_size="1h")
@pytest.mark.anyio
class TestBanTrend:
"""GET /api/dashboard/bans/trend."""
async def test_returns_200_when_authenticated(
self, dashboard_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
with patch(
"app.routers.dashboard.ban_service.ban_trend",
new=AsyncMock(return_value=_make_ban_trend_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/trend")
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/trend")
assert response.status_code == 401
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
"""Response body contains ``buckets`` list and ``bucket_size`` string."""
with patch(
"app.routers.dashboard.ban_service.ban_trend",
new=AsyncMock(return_value=_make_ban_trend_response(24)),
):
response = await dashboard_client.get("/api/dashboard/bans/trend")
body = response.json()
assert "buckets" in body
assert "bucket_size" in body
assert len(body["buckets"]) == 24
assert body["bucket_size"] == "1h"
async def test_each_bucket_has_timestamp_and_count(
self, dashboard_client: AsyncClient
) -> None:
"""Every element of ``buckets`` has ``timestamp`` and ``count``."""
with patch(
"app.routers.dashboard.ban_service.ban_trend",
new=AsyncMock(return_value=_make_ban_trend_response(3)),
):
response = await dashboard_client.get("/api/dashboard/bans/trend")
for bucket in response.json()["buckets"]:
assert "timestamp" in bucket
assert "count" in bucket
assert isinstance(bucket["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_ban_trend_response())
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/trend")
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_ban_trend_response(28))
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/trend?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_ban_trend_response())
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
await dashboard_client.get(
"/api/dashboard/bans/trend?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_ban_trend_response())
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/trend")
_, 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/trend?range=invalid"
)
assert response.status_code == 422
async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
"""Empty bucket list is serialised correctly."""
from app.models.ban import BanTrendResponse
empty = BanTrendResponse(buckets=[], bucket_size="1h")
with patch(
"app.routers.dashboard.ban_service.ban_trend",
new=AsyncMock(return_value=empty),
):
response = await dashboard_client.get("/api/dashboard/bans/trend")
body = response.json()
assert body["buckets"] == []
assert body["bucket_size"] == "1h"

View File

@@ -302,9 +302,10 @@ class TestListBansBatchGeoEnrichment:
self, f2b_db_path: str
) -> None:
"""Geo fields are populated via lookup_batch when http_session is given."""
from app.services.geo_service import GeoInfo
from unittest.mock import MagicMock
from app.services.geo_service import GeoInfo
fake_session = MagicMock()
fake_geo_map = {
"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS3320", org="Deutsche Telekom"),
@@ -357,9 +358,10 @@ class TestListBansBatchGeoEnrichment:
self, f2b_db_path: str
) -> None:
"""When both http_session and geo_enricher are provided, batch wins."""
from app.services.geo_service import GeoInfo
from unittest.mock import MagicMock
from app.services.geo_service import GeoInfo
fake_session = MagicMock()
fake_geo_map = {
"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None),
@@ -610,3 +612,167 @@ class TestOriginFilter:
)
assert result.total == 3
# ---------------------------------------------------------------------------
# ban_trend
# ---------------------------------------------------------------------------
class TestBanTrend:
"""Verify ban_service.ban_trend() behaviour."""
async def test_24h_returns_24_buckets(self, empty_f2b_db_path: str) -> None:
"""``range_='24h'`` always yields exactly 24 buckets."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.ban_trend("/fake/sock", "24h")
assert len(result.buckets) == 24
assert result.bucket_size == "1h"
async def test_7d_returns_28_buckets(self, empty_f2b_db_path: str) -> None:
"""``range_='7d'`` yields 28 six-hour buckets."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.ban_trend("/fake/sock", "7d")
assert len(result.buckets) == 28
assert result.bucket_size == "6h"
async def test_30d_returns_30_buckets(self, empty_f2b_db_path: str) -> None:
"""``range_='30d'`` yields 30 daily buckets."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.ban_trend("/fake/sock", "30d")
assert len(result.buckets) == 30
assert result.bucket_size == "1d"
async def test_365d_bucket_size_label(self, empty_f2b_db_path: str) -> None:
"""``range_='365d'`` uses '7d' as the bucket size label."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.ban_trend("/fake/sock", "365d")
assert result.bucket_size == "7d"
assert len(result.buckets) > 0
async def test_empty_db_all_buckets_zero(self, empty_f2b_db_path: str) -> None:
"""All bucket counts are zero when the database has no bans."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.ban_trend("/fake/sock", "24h")
assert all(b.count == 0 for b in result.buckets)
async def test_buckets_are_time_ordered(self, empty_f2b_db_path: str) -> None:
"""Buckets are ordered chronologically (ascending timestamps)."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.ban_trend("/fake/sock", "7d")
timestamps = [b.timestamp for b in result.buckets]
assert timestamps == sorted(timestamps)
async def test_bans_counted_in_correct_bucket(self, tmp_path: Path) -> None:
"""A ban at a known time appears in the expected bucket."""
import time as _time
now = int(_time.time())
# Place a ban exactly 30 minutes ago — should land in bucket 0 of a 24h range
# (the most recent hour bucket when 'since' is ~24 h ago).
thirty_min_ago = now - 1800
path = str(tmp_path / "test_bucket.sqlite3")
await _create_f2b_db(
path,
[{"jail": "sshd", "ip": "1.2.3.4", "timeofban": thirty_min_ago}],
)
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=path),
):
result = await ban_service.ban_trend("/fake/sock", "24h")
# Total ban count across all buckets must be exactly 1.
assert sum(b.count for b in result.buckets) == 1
async def test_origin_filter_blocklist(self, tmp_path: Path) -> None:
"""``origin='blocklist'`` counts only blocklist-import bans."""
import time as _time
now = int(_time.time())
one_hour_ago = now - 3600
path = str(tmp_path / "test_trend_origin.sqlite3")
await _create_f2b_db(
path,
[
{"jail": "blocklist-import", "ip": "10.0.0.1", "timeofban": one_hour_ago},
{"jail": "sshd", "ip": "10.0.0.2", "timeofban": one_hour_ago},
],
)
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=path),
):
result = await ban_service.ban_trend(
"/fake/sock", "24h", origin="blocklist"
)
assert sum(b.count for b in result.buckets) == 1
async def test_origin_filter_selfblock(self, tmp_path: Path) -> None:
"""``origin='selfblock'`` excludes blocklist-import bans."""
import time as _time
now = int(_time.time())
one_hour_ago = now - 3600
path = str(tmp_path / "test_trend_selfblock.sqlite3")
await _create_f2b_db(
path,
[
{"jail": "blocklist-import", "ip": "10.0.0.1", "timeofban": one_hour_ago},
{"jail": "sshd", "ip": "10.0.0.2", "timeofban": one_hour_ago},
{"jail": "nginx", "ip": "10.0.0.3", "timeofban": one_hour_ago},
],
)
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=path),
):
result = await ban_service.ban_trend(
"/fake/sock", "24h", origin="selfblock"
)
assert sum(b.count for b in result.buckets) == 2
async def test_each_bucket_has_iso_timestamp(self, empty_f2b_db_path: str) -> None:
"""Every bucket timestamp is a valid ISO 8601 string."""
from datetime import datetime
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.ban_trend("/fake/sock", "24h")
for bucket in result.buckets:
# datetime.fromisoformat raises ValueError on invalid input.
parsed = datetime.fromisoformat(bucket.timestamp)
assert parsed.tzinfo is not None # Must be timezone-aware (UTC)