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

@@ -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_],
)