Files
BanGUI/backend/app/routers/dashboard.py
Lukas 9242b4709a 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
2026-03-11 16:38:19 +01:00

209 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dashboard router.
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,
``GET /api/dashboard/bans/by-country`` for country aggregation, and
``GET /api/dashboard/bans/trend`` for time-bucketed ban counts.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiohttp
from fastapi import APIRouter, Query, Request
from app.dependencies import AuthDep
from app.models.ban import (
BanOrigin,
BansByCountryResponse,
BanTrendResponse,
DashboardBanListResponse,
TimeRange,
)
from app.models.server import ServerStatus, ServerStatusResponse
from app.services import ban_service
router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
# ---------------------------------------------------------------------------
# Default pagination constants
# ---------------------------------------------------------------------------
_DEFAULT_PAGE_SIZE: int = 100
_DEFAULT_RANGE: TimeRange = "24h"
@router.get(
"/status",
response_model=ServerStatusResponse,
summary="Return the cached fail2ban server status",
)
async def get_server_status(
request: Request,
_auth: AuthDep,
) -> ServerStatusResponse:
"""Return the most recent fail2ban health snapshot.
The snapshot is populated by a background task that runs every 30 seconds.
If the task has not yet executed a placeholder ``online=False`` status is
returned so the response is always well-formed.
Args:
request: The incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication on this endpoint.
Returns:
:class:`~app.models.server.ServerStatusResponse` containing the
current health snapshot.
"""
cached: ServerStatus = getattr(
request.app.state,
"server_status",
ServerStatus(online=False),
)
return ServerStatusResponse(status=cached)
@router.get(
"/bans",
response_model=DashboardBanListResponse,
summary="Return a paginated list of recent bans",
)
async def get_dashboard_bans(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
),
) -> DashboardBanListResponse:
"""Return a paginated list of bans within the selected time window.
Reads from the fail2ban database and enriches each entry with
geolocation data (country, ASN, organisation) from the ip-api.com
free API. Results are sorted newest-first. Geo lookups are served
from the in-memory cache only; no database writes occur during this
GET request.
Args:
request: The incoming request (used to access ``app.state``).
_auth: Validated session dependency.
range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or
``"365d"``.
page: 1-based page number.
page_size: Maximum items per page (1500).
origin: Optional filter by ban origin.
Returns:
:class:`~app.models.ban.DashboardBanListResponse` with paginated
ban items and the total count for the selected window.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
return await ban_service.list_bans(
socket_path,
range,
page=page,
page_size=page_size,
http_session=http_session,
app_db=None,
origin=origin,
)
@router.get(
"/bans/by-country",
response_model=BansByCountryResponse,
summary="Return ban counts aggregated by country",
)
async def get_bans_by_country(
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.",
),
) -> BansByCountryResponse:
"""Return ban counts aggregated by ISO country code.
Uses SQL aggregation (``GROUP BY ip``) and batch geo-resolution to handle
10 000+ banned IPs efficiently. Returns a ``{country_code: count}`` map
and the 200 most recent raw ban rows for the companion access table. Geo
lookups are served from the in-memory cache only; no database writes occur
during this GET request.
Args:
request: The incoming request.
_auth: Validated session dependency.
range: Time-range preset.
origin: Optional filter by ban origin.
Returns:
:class:`~app.models.ban.BansByCountryResponse` with per-country
aggregation and the companion ban list.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
return await ban_service.bans_by_country(
socket_path,
range,
http_session=http_session,
app_db=None,
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)