feat: implement dashboard ban overview (Stage 5)
- Add ban_service reading fail2ban SQLite DB via read-only aiosqlite - Add geo_service resolving IPs via ip-api.com with 10k in-memory cache - Add GET /api/dashboard/bans and GET /api/dashboard/accesses endpoints - Add TimeRange, DashboardBanItem, DashboardBanListResponse, AccessListItem, AccessListResponse models in models/ban.py - Build BanTable component (Fluent UI DataGrid) with bans/accesses modes, pagination, loading/error/empty states, and ban-count badges - Build useBans hook managing time-range and pagination state - Update DashboardPage: status bar + time-range toolbar + tab switcher - Add 37 new backend tests (ban service, geo service, dashboard router) - All 141 tests pass; ruff/mypy --strict/tsc --noEmit clean
This commit is contained in:
@@ -3,17 +3,38 @@
|
||||
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`` and ``GET /api/dashboard/accesses``
|
||||
for the dashboard ban-list and access-list tables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
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 (
|
||||
AccessListResponse,
|
||||
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",
|
||||
@@ -44,3 +65,94 @@ async def get_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(
|
||||
"/accesses",
|
||||
response_model=AccessListResponse,
|
||||
summary="Return a paginated list of individual access events",
|
||||
)
|
||||
async def get_dashboard_accesses(
|
||||
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."),
|
||||
) -> AccessListResponse:
|
||||
"""Return a paginated list of individual access events (matched log lines).
|
||||
|
||||
Expands the ``data.matches`` JSON stored inside each ban record so that
|
||||
every matched log line is returned as a separate row. Useful for
|
||||
the "Access List" tab which shows all recorded access attempts — not
|
||||
just the aggregate bans.
|
||||
|
||||
Args:
|
||||
request: The incoming request.
|
||||
_auth: Validated session dependency.
|
||||
range: Time-range preset.
|
||||
page: 1-based page number.
|
||||
page_size: Maximum items per page (1–500).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.ban.AccessListResponse` with individual access
|
||||
items expanded from ``data.matches``.
|
||||
"""
|
||||
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_accesses(
|
||||
socket_path,
|
||||
range,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
geo_enricher=_enricher,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user