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