"""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. """ 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 ( BansByCountryResponse, DashboardBanListResponse, TimeRange, ) from app.models.server import ServerStatus, ServerStatusResponse from app.services import ban_service, geo_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."), ) -> 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. 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). 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 async def _enricher(ip: str) -> geo_service.GeoInfo | None: return await geo_service.lookup(ip, http_session) return await ban_service.list_bans( socket_path, range, page=page, page_size=page_size, geo_enricher=_enricher, ) @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."), ) -> BansByCountryResponse: """Return ban counts aggregated by ISO country code. Fetches up to 2 000 ban records in the selected time window, enriches every record with geo data, and returns a ``{country_code: count}`` map plus the full enriched ban list for the companion access table. Args: request: The incoming request. _auth: Validated session dependency. range: Time-range preset. Returns: :class:`~app.models.ban.BansByCountryResponse` with per-country aggregation and the full ban list. """ socket_path: str = request.app.state.settings.fail2ban_socket http_session: aiohttp.ClientSession = request.app.state.http_session async def _enricher(ip: str) -> geo_service.GeoInfo | None: return await geo_service.lookup(ip, http_session) return await ban_service.bans_by_country( socket_path, range, geo_enricher=_enricher, )