- backend: GET /api/dashboard/bans/by-jail endpoint - JailBanCount + BansByJailResponse Pydantic models in ban.py - bans_by_jail() service function with origin filter support - Route added to dashboard router - 17 new tests (7 service, 10 router); full suite 497 passed, 83% coverage - frontend: JailDistributionChart component - JailBanCount / BansByJailResponse types in types/ban.ts - dashboardBansByJail endpoint constant in api/endpoints.ts - fetchBansByJail() in api/dashboard.ts - useJailDistribution hook in hooks/useJailDistribution.ts - JailDistributionChart component (horizontal bar chart, Recharts) - DashboardPage: full-width Jail Distribution section below Top Countries
247 lines
8.2 KiB
Python
247 lines
8.2 KiB
Python
"""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,
|
||
``GET /api/dashboard/bans/trend`` for time-bucketed ban counts, and
|
||
``GET /api/dashboard/bans/by-jail`` for per-jail 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,
|
||
BansByJailResponse,
|
||
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 (1–500).
|
||
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)
|
||
|
||
|
||
@router.get(
|
||
"/bans/by-jail",
|
||
response_model=BansByJailResponse,
|
||
summary="Return ban counts aggregated by jail",
|
||
)
|
||
async def get_bans_by_jail(
|
||
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.",
|
||
),
|
||
) -> BansByJailResponse:
|
||
"""Return ban counts grouped by jail name for the selected time window.
|
||
|
||
Queries the fail2ban database and returns a list of jails sorted by
|
||
ban count descending. This endpoint is intended for the dashboard jail
|
||
distribution bar chart.
|
||
|
||
Args:
|
||
request: The incoming request (used to access ``app.state``).
|
||
_auth: Validated session dependency.
|
||
range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or
|
||
``"365d"``.
|
||
origin: Optional filter by ban origin.
|
||
|
||
Returns:
|
||
:class:`~app.models.ban.BansByJailResponse` with per-jail counts
|
||
sorted descending and the total for the selected window.
|
||
"""
|
||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||
|
||
return await ban_service.bans_by_jail(socket_path, range, origin=origin)
|