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:
@@ -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').",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user