Stage 9: ban history — backend service, router, frontend history page
- history.py models: HistoryBanItem, HistoryListResponse, IpTimelineEvent, IpDetailResponse
- history_service.py: list_history() with dynamic WHERE clauses (range/jail/ip
prefix/all-time), get_ip_detail() with timeline aggregation
- history.py router: GET /api/history + GET /api/history/{ip} (404 for unknown)
- Fixed latent bug in ban_service._parse_data_json: json.loads('null') -> None
-> AttributeError; now checks isinstance(parsed, dict) before assigning obj
- 317 tests pass (27 new), ruff + mypy clean (46 files)
- types/history.ts, api/history.ts, hooks/useHistory.ts created
- HistoryPage.tsx: filter bar (time range/jail/IP), DataGrid table,
high-ban-count row highlighting, per-IP IpDetailView with timeline,
pagination
- Frontend tsc + ESLint clean (0 errors/warnings)
- Tasks.md Stage 9 marked done
This commit is contained in:
141
backend/app/routers/history.py
Normal file
141
backend/app/routers/history.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""History router.
|
||||
|
||||
Provides endpoints for forensic exploration of all historical ban records
|
||||
stored in the fail2ban SQLite database.
|
||||
|
||||
Routes
|
||||
------
|
||||
``GET /api/history``
|
||||
Paginated list of all historical bans, filterable by jail, IP prefix, and
|
||||
time range.
|
||||
|
||||
``GET /api/history/{ip}``
|
||||
Per-IP detail: complete ban timeline, aggregated totals, and geolocation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.ban import TimeRange
|
||||
from app.models.history import HistoryListResponse, IpDetailResponse
|
||||
from app.services import geo_service, history_service
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/history", tags=["History"])
|
||||
|
||||
_DEFAULT_PAGE_SIZE: int = 100
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=HistoryListResponse,
|
||||
summary="Return a paginated list of historical bans",
|
||||
)
|
||||
async def get_history(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
range: TimeRange | None = Query(
|
||||
default=None,
|
||||
description="Optional time-range filter. Omit for all-time.",
|
||||
),
|
||||
jail: str | None = Query(
|
||||
default=None,
|
||||
description="Restrict results to this jail name.",
|
||||
),
|
||||
ip: str | None = Query(
|
||||
default=None,
|
||||
description="Restrict results to IPs matching this prefix.",
|
||||
),
|
||||
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 (max 500).",
|
||||
),
|
||||
) -> HistoryListResponse:
|
||||
"""Return a paginated list of historical bans with optional filters.
|
||||
|
||||
Queries the fail2ban database for all ban records, applying the requested
|
||||
filters. Results are ordered newest-first and enriched with geolocation.
|
||||
|
||||
Args:
|
||||
request: The incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
range: Optional time-range preset. ``None`` means all-time.
|
||||
jail: Optional jail name filter (exact match).
|
||||
ip: Optional IP prefix filter (prefix match).
|
||||
page: 1-based page number.
|
||||
page_size: Items per page (1–500).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.history.HistoryListResponse` with paginated items
|
||||
and the total matching count.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
||||
|
||||
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
|
||||
return await geo_service.lookup(addr, http_session)
|
||||
|
||||
return await history_service.list_history(
|
||||
socket_path,
|
||||
range_=range,
|
||||
jail=jail,
|
||||
ip_filter=ip,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
geo_enricher=_enricher,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ip}",
|
||||
response_model=IpDetailResponse,
|
||||
summary="Return the full ban history for a single IP address",
|
||||
)
|
||||
async def get_ip_history(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
ip: str,
|
||||
) -> IpDetailResponse:
|
||||
"""Return the complete historical record for a single IP address.
|
||||
|
||||
Fetches all ban events for the given IP from the fail2ban database and
|
||||
aggregates them into a timeline. Returns ``404`` if the IP has no
|
||||
recorded history.
|
||||
|
||||
Args:
|
||||
request: The incoming request.
|
||||
_auth: Validated session dependency.
|
||||
ip: The IP address to look up.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.history.IpDetailResponse` with aggregated totals
|
||||
and a full ban timeline.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if the IP has no history in the database.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
||||
|
||||
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
|
||||
return await geo_service.lookup(addr, http_session)
|
||||
|
||||
detail: IpDetailResponse | None = await history_service.get_ip_detail(
|
||||
socket_path,
|
||||
ip,
|
||||
geo_enricher=_enricher,
|
||||
)
|
||||
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail=f"No history found for IP {ip!r}.")
|
||||
|
||||
return detail
|
||||
Reference in New Issue
Block a user