"""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 Literal from fastapi import APIRouter, Query from app import __version__ from app.dependencies import ( AuthDep, BanServiceContextDep, Fail2BanSocketDep, GeoCacheDep, HttpSessionDep, ServerStatusDep, SettingsDep, ) from app.mappers import ( map_domain_ban_trend_to_response, map_domain_bans_by_country_to_response, map_domain_bans_by_jail_to_response, map_domain_dashboard_ban_list_to_response, ) from app.models._common import TimeRange from app.models.ban import ( BanOrigin, BansByCountryResponse, BansByJailResponse, BanTrendResponse, DashboardBanListResponse, ) from app.models.server import ServerStatus, ServerStatusResponse from app.services import ban_service from app.utils.constants import DEFAULT_PAGE_SIZE router: APIRouter = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"]) # --------------------------------------------------------------------------- # Default pagination constants # --------------------------------------------------------------------------- _DEFAULT_RANGE: TimeRange = "24h" @router.get( "/status", response_model=ServerStatusResponse, summary="Return the cached fail2ban server status", responses={ 200: {"description": "Server status returned", "model": ServerStatusResponse}, 401: {"description": "Session missing, expired, or invalid"}, 502: {"description": "fail2ban unreachable"}, }, ) async def get_server_status( server_status: ServerStatusDep, _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: server_status: Cached fail2ban server health snapshot (injected). _auth: Validated session — enforces authentication on this endpoint. Returns: :class:`~app.models.server.ServerStatusResponse` containing the current health snapshot. """ cached: ServerStatus = server_status cached.version = __version__ return ServerStatusResponse(status=cached) @router.get( "/bans", response_model=DashboardBanListResponse, summary="Return a paginated list of recent bans", responses={ 200: {"description": "Ban list returned", "model": DashboardBanListResponse}, 401: {"description": "Session missing, expired, or invalid"}, 502: {"description": "fail2ban unreachable"}, }, ) async def get_dashboard_bans( _auth: AuthDep, ban_ctx: BanServiceContextDep, socket_path: Fail2BanSocketDep, http_session: HttpSessionDep, geo_cache: GeoCacheDep, settings: SettingsDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), source: Literal["fail2ban", "archive"] = Query( default="fail2ban", description="Data source: 'fail2ban' or 'archive'.", ), page: int = Query(default=1, ge=1, description="1-based page number."), page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, 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: _auth: Validated session dependency. ban_ctx: Ban service context containing db and repository. socket_path: Path to fail2ban Unix domain socket. http_session: Shared HTTP session for geolocation. geo_cache: Geolocation cache instance. 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. """ domain_result = await ban_service.list_bans( socket_path, range, source=source, page=page, page_size=page_size, max_page_size=settings.max_page_size, http_session=http_session, app_db=ban_ctx.db, geo_cache=geo_cache, origin=origin, ) return map_domain_dashboard_ban_list_to_response(domain_result) @router.get( "/bans/by-country", response_model=BansByCountryResponse, summary="Return ban counts aggregated by country", responses={ 200: {"description": "Ban counts by country returned", "model": BansByCountryResponse}, 401: {"description": "Session missing, expired, or invalid"}, 502: {"description": "fail2ban unreachable"}, }, ) async def get_bans_by_country( _auth: AuthDep, ban_ctx: BanServiceContextDep, socket_path: Fail2BanSocketDep, http_session: HttpSessionDep, geo_cache: GeoCacheDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), source: Literal["fail2ban", "archive"] = Query( default="fail2ban", description="Data source: 'fail2ban' or 'archive'.", ), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", ), country_code: str | None = Query( default=None, description="ISO alpha-2 country code to filter companion rows.", ), ) -> 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: _auth: Validated session dependency. ban_ctx: Ban service context containing db and repository. socket_path: Path to fail2ban Unix domain socket. http_session: Shared HTTP session for geolocation. geo_cache: Geolocation cache instance. 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. """ domain_result = await ban_service.bans_by_country( socket_path, range, source=source, http_session=http_session, geo_cache_lookup=geo_cache.lookup_cached_only, geo_cache=geo_cache, app_db=ban_ctx.db, origin=origin, country_code=country_code, ) return map_domain_bans_by_country_to_response(domain_result) @router.get( "/bans/trend", response_model=BanTrendResponse, summary="Return ban counts aggregated into time buckets", responses={ 200: {"description": "Ban trend data returned", "model": BanTrendResponse}, 401: {"description": "Session missing, expired, or invalid"}, 502: {"description": "fail2ban unreachable"}, }, ) async def get_ban_trend( _auth: AuthDep, ban_ctx: BanServiceContextDep, socket_path: Fail2BanSocketDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), source: Literal["fail2ban", "archive"] = Query( default="fail2ban", description="Data source: 'fail2ban' or 'archive'.", ), 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: _auth: Validated session dependency. ban_ctx: Ban service context containing db and repository. socket_path: Path to fail2ban Unix domain socket. 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. """ domain_result = await ban_service.ban_trend( socket_path, range, source=source, app_db=ban_ctx.db, origin=origin, ) return map_domain_ban_trend_to_response(domain_result) @router.get( "/bans/by-jail", response_model=BansByJailResponse, summary="Return ban counts aggregated by jail", responses={ 200: {"description": "Ban counts by jail returned", "model": BansByJailResponse}, 401: {"description": "Session missing, expired, or invalid"}, 502: {"description": "fail2ban unreachable"}, }, ) async def get_bans_by_jail( _auth: AuthDep, ban_ctx: BanServiceContextDep, socket_path: Fail2BanSocketDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), source: Literal["fail2ban", "archive"] = Query( default="fail2ban", description="Data source: 'fail2ban' or 'archive'.", ), 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: _auth: Validated session dependency. ban_ctx: Ban service context containing db and repository. socket_path: Path to fail2ban Unix domain socket. 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. """ domain_result = await ban_service.bans_by_jail( socket_path, range, source=source, app_db=ban_ctx.db, origin=origin, ) return map_domain_bans_by_jail_to_response(domain_result)