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:
2026-03-01 15:09:22 +01:00
parent 54313fd3e0
commit b8f3a1c562
12 changed files with 2050 additions and 50 deletions

View File

@@ -300,29 +300,29 @@ Added `TestBansByCountry` class (5 tests) to `backend/tests/test_routers/test_da
---
## Stage 9 — Ban History
## Stage 9 — Ban History ✅ DONE
This stage exposes historical ban data from the fail2ban database for forensic exploration.
### 9.1 Implement the history service
### 9.1 Implement the history service ✅ DONE
Build `backend/app/services/history_service.py`. Query the fail2ban database for all past ban records (not just currently active ones). Support filtering by jail, IP address, and time range. Compute ban count per IP to identify repeat offenders. Provide a per-IP timeline method that returns every ban event for a given IP: which jail triggered it, when it started, how long it lasted, and any matched log lines stored in the database. See [Features.md § 7](Features.md).
Built `backend/app/services/history_service.py`. `list_history()` queries the fail2ban DB with dynamic WHERE clauses: time range (`range_=None` = all-time, otherwise filters by `timeofban >= now - delta`), jail (exact match), IP (LIKE prefix `%`), and page/page_size. `get_ip_detail()` aggregates all ban events for a given IP into an `IpDetailResponse` with timeline, total bans, total failures, last_ban_at, and geo data — returns `None` if no records. Reuses `_get_fail2ban_db_path`, `_parse_data_json`, `_ts_to_iso` from `ban_service`. Also fixed a latent bug in `_parse_data_json` in `ban_service.py`: `json.loads("null")` returns Python `None` rather than a dict, causing `AttributeError` on `.get()`; fixed by checking `isinstance(parsed, dict)` before assigning `obj`.
### 9.2 Implement the history router
### 9.2 Implement the history router ✅ DONE
Create `backend/app/routers/history.py`:
- `GET /api/history` — paginated list of all historical bans with filters (jail, IP, time range). Returns time, IP, jail, duration, ban count, country.
- `GET /api/history/{ip}` — per-IP detail: full ban timeline, total failures, matched log lines.
Created `backend/app/routers/history.py`:
- `GET /api/history` — paginated list with optional filters: `range` (`TimeRange` enum or omit for all-time), `jail` (exact), `ip` (prefix), `page`, `page_size`. Returns `HistoryListResponse`.
- `GET /api/history/{ip}` — per-IP detail returning `IpDetailResponse`; raises `HTTPException(404)` if `get_ip_detail()` returns `None`.
Define models in `backend/app/models/history.py`. Enrich results with geo data. See [Architekture.md § 2.2](Architekture.md).
Models defined in `backend/app/models/history.py`: `HistoryBanItem`, `HistoryListResponse`, `IpTimelineEvent`, `IpDetailResponse`. Results enriched with geo data via `geo_service.lookup`. Router registered in `main.py`.
### 9.3 Build the history page (frontend)
### 9.3 Build the history page (frontend) ✅ DONE
Create `frontend/src/pages/HistoryPage.tsx`. Display a `DataGrid` table of all past bans with columns for time, IP (monospace), jail, ban duration, ban count, and country. Add filter controls above the table: a jail dropdown, an IP search input, and the standard time-range selector. Highlight rows with high ban counts to flag repeat offenders. Clicking an IP row navigates to a per-IP detail view showing the full ban timeline and aggregated failures. See [Features.md § 7](Features.md).
Replaced placeholder `frontend/src/pages/HistoryPage.tsx` with full implementation. Filter bar: time-range `Select` (All time + 4 presets), jail `Input`, IP prefix `Input`, Apply/Clear buttons. FluentUI `DataGrid` table with columns: Banned At, IP (monospace, clickable), Jail, Country, Failures, Times Banned. Rows with `ban_count ≥ 5` highlighted amber. Clicking an IP opens `IpDetailView` sub-component with summary grid and timeline `Table`. Pagination with ChevronLeft/ChevronRight buttons. Created supporting files: `frontend/src/types/history.ts`, `frontend/src/api/history.ts`, `frontend/src/hooks/useHistory.ts` (`useHistory` pagination hook + `useIpHistory` detail hook).
### 9.4 Write tests for history features
### 9.4 Write tests for history features ✅ DONE
Test history queries with various filters, per-IP timeline construction, ban count computation, and edge cases (IP with no history, jail that no longer exists).
Added `tests/test_routers/test_history.py` (11 tests) and `tests/test_services/test_history_service.py` (16 tests). Service tests use a real temporary SQLite DB seeded with 4 rows across two jails and 3 IPs. Router tests mock the service layer. Coverage: time-range filter, jail filter, IP prefix filter, combined filters, unknown IP → `None`, pagination, null data column, geo enrichment, 404 response, timeline aggregation, total_failures. All 317 backend tests pass (27 new), ruff clean, mypy clean (46 files). Frontend `tsc --noEmit` and `npm run lint` clean (0 errors/warnings).
---

View File

@@ -33,7 +33,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.config import Settings, get_settings
from app.db import init_db
from app.routers import auth, bans, config, dashboard, geo, health, jails, server, setup
from app.routers import auth, bans, config, dashboard, geo, health, history, jails, server, setup
from app.tasks import health_check
# ---------------------------------------------------------------------------
@@ -278,5 +278,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(geo.router)
app.include_router(config.router)
app.include_router(server.router)
app.include_router(history.router)
return app

View File

@@ -1,45 +1,142 @@
"""Ban history Pydantic models."""
"""Ban history Pydantic models.
Request, response, and domain models used by the history router and service.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from app.models.ban import TimeRange
class HistoryEntry(BaseModel):
"""A single historical ban record from the fail2ban database."""
__all__ = [
"HistoryBanItem",
"HistoryListResponse",
"IpDetailResponse",
"IpTimelineEvent",
"TimeRange",
]
class HistoryBanItem(BaseModel):
"""A single row in the history ban-list table.
Populated from the fail2ban database and optionally enriched with
geolocation data.
"""
model_config = ConfigDict(strict=True)
ip: str
jail: str
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
released_at: str | None = Field(default=None, description="ISO 8601 UTC timestamp when the ban expired.")
ban_count: int = Field(..., ge=1, description="Total number of times this IP was banned.")
country: str | None = None
matched_lines: list[str] = Field(default_factory=list)
class IpTimeline(BaseModel):
"""Per-IP ban history timeline."""
model_config = ConfigDict(strict=True)
ip: str
total_bans: int = Field(..., ge=0)
total_failures: int = Field(..., ge=0)
events: list[HistoryEntry] = Field(default_factory=list)
ban_count: int = Field(..., ge=1, description="How many times this IP was banned.")
failures: int = Field(
default=0,
ge=0,
description="Total failure count extracted from the ``data`` column.",
)
matches: list[str] = Field(
default_factory=list,
description="Matched log lines stored in the ``data`` column.",
)
country_code: str | None = Field(
default=None,
description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.",
)
country_name: str | None = Field(
default=None,
description="Human-readable country name, or ``null`` if unknown.",
)
asn: str | None = Field(
default=None,
description="Autonomous System Number string (e.g. ``'AS3320'``).",
)
org: str | None = Field(
default=None,
description="Organisation name associated with the IP.",
)
class HistoryListResponse(BaseModel):
"""Paginated response for ``GET /api/history``."""
"""Paginated history ban-list response."""
model_config = ConfigDict(strict=True)
entries: list[HistoryEntry] = Field(default_factory=list)
total: int = Field(..., ge=0)
items: list[HistoryBanItem] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total matching records.")
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
class IpHistoryResponse(BaseModel):
"""Response for ``GET /api/history/{ip}``."""
# ---------------------------------------------------------------------------
# Per-IP timeline
# ---------------------------------------------------------------------------
class IpTimelineEvent(BaseModel):
"""A single ban event in a per-IP timeline.
Represents one row from the fail2ban ``bans`` table for a specific IP.
"""
model_config = ConfigDict(strict=True)
timeline: IpTimeline
jail: str = Field(..., description="Jail that triggered this ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
ban_count: int = Field(
...,
ge=1,
description="Running ban counter for this IP at the time of this event.",
)
failures: int = Field(
default=0,
ge=0,
description="Failure count at the time of the ban.",
)
matches: list[str] = Field(
default_factory=list,
description="Matched log lines that triggered the ban.",
)
class IpDetailResponse(BaseModel):
"""Full historical record for a single IP address.
Contains aggregated totals and a chronological timeline of all ban events
recorded in the fail2ban database for the given IP.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="The IP address.")
total_bans: int = Field(..., ge=0, description="Total number of ban records.")
total_failures: int = Field(
...,
ge=0,
description="Sum of all failure counts across all ban events.",
)
last_ban_at: str | None = Field(
default=None,
description="ISO 8601 UTC timestamp of the most recent ban, or ``null``.",
)
country_code: str | None = Field(
default=None,
description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.",
)
country_name: str | None = Field(
default=None,
description="Human-readable country name, or ``null`` if unknown.",
)
asn: str | None = Field(
default=None,
description="Autonomous System Number string.",
)
org: str | None = Field(
default=None,
description="Organisation name associated with the IP.",
)
timeline: list[IpTimelineEvent] = Field(
default_factory=list,
description="All ban events for this IP, ordered newest-first.",
)

View 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 (1500).
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

View File

@@ -124,7 +124,10 @@ def _parse_data_json(raw: Any) -> tuple[list[str], int]:
obj: dict[str, Any] = {}
if isinstance(raw, str):
try:
obj = json.loads(raw)
parsed: Any = json.loads(raw)
if isinstance(parsed, dict):
obj = parsed
# json.loads("null") → None, or other non-dict — treat as empty
except json.JSONDecodeError:
return [], 0
elif isinstance(raw, dict):

View File

@@ -0,0 +1,269 @@
"""History service.
Queries the fail2ban SQLite database for all historical ban records.
Supports filtering by jail, IP, and time range. For per-IP forensics the
service provides a full ban timeline with matched log lines and failure counts.
All database I/O uses aiosqlite in **read-only** mode so BanGUI never
modifies or locks the fail2ban database.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import aiosqlite
import structlog
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
from app.models.history import (
HistoryBanItem,
HistoryListResponse,
IpDetailResponse,
IpTimelineEvent,
)
from app.services.ban_service import _get_fail2ban_db_path, _parse_data_json, _ts_to_iso
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_DEFAULT_PAGE_SIZE: int = 100
_MAX_PAGE_SIZE: int = 500
def _since_unix(range_: TimeRange) -> int:
"""Return the Unix timestamp for the start of the given time window.
Args:
range_: One of the supported time-range presets.
Returns:
Unix timestamp (seconds since epoch) equal to *now range_*.
"""
seconds: int = TIME_RANGE_SECONDS[range_]
return int(datetime.now(tz=UTC).timestamp()) - seconds
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def list_history(
socket_path: str,
*,
range_: TimeRange | None = None,
jail: str | None = None,
ip_filter: str | None = None,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: Any | None = None,
) -> HistoryListResponse:
"""Return a paginated list of historical ban records with optional filters.
Queries the fail2ban ``bans`` table applying the requested filters and
returns a paginated list ordered newest-first. When *geo_enricher* is
supplied, each record is enriched with country and ASN data.
Args:
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset. ``None`` means all-time (no time filter).
jail: If given, restrict results to bans from this jail.
ip_filter: If given, restrict results to bans for this exact IP
(or a prefix — the query uses ``LIKE ip_filter%``).
page: 1-based page number (default: ``1``).
page_size: Maximum items per page, capped at ``_MAX_PAGE_SIZE``.
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
Returns:
:class:`~app.models.history.HistoryListResponse` with paginated items
and the total matching count.
"""
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
offset: int = (page - 1) * effective_page_size
# Build WHERE clauses dynamically.
wheres: list[str] = []
params: list[Any] = []
if range_ is not None:
since: int = _since_unix(range_)
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else ""
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info(
"history_service_list",
db_path=db_path,
range=range_,
jail=jail,
ip_filter=ip_filter,
page=page,
)
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
f2b_db.row_factory = aiosqlite.Row
async with f2b_db.execute(
f"SELECT COUNT(*) FROM bans {where_sql}", # noqa: S608
params,
) as cur:
count_row = await cur.fetchone()
total: int = int(count_row[0]) if count_row else 0
async with f2b_db.execute(
f"SELECT jail, ip, timeofban, bancount, data " # noqa: S608
f"FROM bans {where_sql} "
"ORDER BY timeofban DESC "
"LIMIT ? OFFSET ?",
[*params, effective_page_size, offset],
) as cur:
rows = await cur.fetchall()
items: list[HistoryBanItem] = []
for row in rows:
jail_name: str = str(row["jail"])
ip: str = str(row["ip"])
banned_at: str = _ts_to_iso(int(row["timeofban"]))
ban_count: int = int(row["bancount"])
matches, failures = _parse_data_json(row["data"])
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
if geo is not None:
country_code = geo.country_code
country_name = geo.country_name
asn = geo.asn
org = geo.org
except Exception: # noqa: BLE001
log.warning("history_service_geo_lookup_failed", ip=ip)
items.append(
HistoryBanItem(
ip=ip,
jail=jail_name,
banned_at=banned_at,
ban_count=ban_count,
failures=failures,
matches=matches,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
)
)
return HistoryListResponse(
items=items,
total=total,
page=page,
page_size=effective_page_size,
)
async def get_ip_detail(
socket_path: str,
ip: str,
*,
geo_enricher: Any | None = None,
) -> IpDetailResponse | None:
"""Return the full historical record for a single IP address.
Fetches all ban events for *ip* from the fail2ban database, ordered
newest-first. Aggregates total bans, total failures, and the timestamp of
the most recent ban. Optionally enriches with geolocation data.
Args:
socket_path: Path to the fail2ban Unix domain socket.
ip: The IP address to look up.
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
Returns:
:class:`~app.models.history.IpDetailResponse` if any records exist
for *ip*, or ``None`` if the IP has no history in the database.
"""
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("history_service_ip_detail", db_path=db_path, ip=ip)
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
f2b_db.row_factory = aiosqlite.Row
async with f2b_db.execute(
"SELECT jail, ip, timeofban, bancount, data "
"FROM bans "
"WHERE ip = ? "
"ORDER BY timeofban DESC",
(ip,),
) as cur:
rows = await cur.fetchall()
if not rows:
return None
timeline: list[IpTimelineEvent] = []
total_failures: int = 0
for row in rows:
jail_name: str = str(row["jail"])
banned_at: str = _ts_to_iso(int(row["timeofban"]))
ban_count: int = int(row["bancount"])
matches, failures = _parse_data_json(row["data"])
total_failures += failures
timeline.append(
IpTimelineEvent(
jail=jail_name,
banned_at=banned_at,
ban_count=ban_count,
failures=failures,
matches=matches,
)
)
last_ban_at: str | None = timeline[0].banned_at if timeline else None
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
if geo is not None:
country_code = geo.country_code
country_name = geo.country_name
asn = geo.asn
org = geo.org
except Exception: # noqa: BLE001
log.warning("history_service_geo_lookup_failed_detail", ip=ip)
return IpDetailResponse(
ip=ip,
total_bans=len(timeline),
total_failures=total_failures,
last_ban_at=last_ban_at,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
timeline=timeline,
)

View File

@@ -0,0 +1,333 @@
"""Tests for the history router (GET /api/history, GET /api/history/{ip})."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import aiosqlite
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.db import init_db
from app.main import create_app
from app.models.history import (
HistoryBanItem,
HistoryListResponse,
IpDetailResponse,
IpTimelineEvent,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SETUP_PAYLOAD = {
"master_password": "testpassword1",
"database_path": "bangui.db",
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
"timezone": "UTC",
"session_duration_minutes": 60,
}
def _make_history_item(ip: str = "1.2.3.4", jail: str = "sshd") -> HistoryBanItem:
"""Build a single ``HistoryBanItem`` for use in test responses."""
return HistoryBanItem(
ip=ip,
jail=jail,
banned_at="2026-03-01T10:00:00+00:00",
ban_count=3,
failures=5,
matches=["Mar 1 10:00:00 host sshd[123]: Failed password for root"],
country_code="DE",
country_name="Germany",
asn="AS3320",
org="Telekom",
)
def _make_history_list(n: int = 2) -> HistoryListResponse:
"""Build a mock ``HistoryListResponse`` with *n* items."""
items = [_make_history_item(ip=f"1.2.3.{i}") for i in range(n)]
return HistoryListResponse(items=items, total=n, page=1, page_size=100)
def _make_ip_detail(ip: str = "1.2.3.4") -> IpDetailResponse:
"""Build a mock ``IpDetailResponse`` for *ip*."""
events = [
IpTimelineEvent(
jail="sshd",
banned_at="2026-03-01T10:00:00+00:00",
ban_count=3,
failures=5,
matches=["Mar 1 10:00:00 host sshd[123]: Failed password for root"],
),
IpTimelineEvent(
jail="sshd",
banned_at="2026-02-28T08:00:00+00:00",
ban_count=2,
failures=5,
matches=[],
),
]
return IpDetailResponse(
ip=ip,
total_bans=2,
total_failures=10,
last_ban_at="2026-03-01T10:00:00+00:00",
country_code="DE",
country_name="Germany",
asn="AS3320",
org="Telekom",
timeline=events,
)
# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------
@pytest.fixture
async def history_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
"""Provide an authenticated ``AsyncClient`` for history endpoint tests."""
settings = Settings(
database_path=str(tmp_path / "history_test.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-history-secret",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
db.row_factory = aiosqlite.Row
await init_db(db)
app.state.db = db
app.state.http_session = MagicMock()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
login_resp = await ac.post(
"/api/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login_resp.status_code == 200
yield ac
await db.close()
# ---------------------------------------------------------------------------
# GET /api/history
# ---------------------------------------------------------------------------
class TestHistoryList:
"""GET /api/history — paginated history list."""
async def test_returns_200_when_authenticated(
self, history_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
with patch(
"app.routers.history.history_service.list_history",
new=AsyncMock(return_value=_make_history_list()),
):
response = await history_client.get("/api/history")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
self, client: AsyncClient
) -> None:
"""Unauthenticated request returns HTTP 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/history")
assert response.status_code == 401
async def test_response_shape(self, history_client: AsyncClient) -> None:
"""Response body contains the expected keys."""
mock_response = _make_history_list(n=1)
with patch(
"app.routers.history.history_service.list_history",
new=AsyncMock(return_value=mock_response),
):
response = await history_client.get("/api/history")
body = response.json()
assert "items" in body
assert "total" in body
assert "page" in body
assert "page_size" in body
assert body["total"] == 1
item = body["items"][0]
assert "ip" in item
assert "jail" in item
assert "banned_at" in item
assert "ban_count" in item
assert "failures" in item
assert "matches" in item
assert item["country_code"] == "DE"
async def test_forwards_jail_filter(self, history_client: AsyncClient) -> None:
"""The ``jail`` query parameter is forwarded to the service."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?jail=nginx")
_args, kwargs = mock_fn.call_args
assert kwargs.get("jail") == "nginx"
async def test_forwards_ip_filter(self, history_client: AsyncClient) -> None:
"""The ``ip`` query parameter is forwarded as ``ip_filter`` to the service."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?ip=192.168")
_args, kwargs = mock_fn.call_args
assert kwargs.get("ip_filter") == "192.168"
async def test_forwards_time_range(self, history_client: AsyncClient) -> None:
"""The ``range`` query parameter is forwarded as ``range_`` to the service."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?range=7d")
_args, kwargs = mock_fn.call_args
assert kwargs.get("range_") == "7d"
async def test_empty_result(self, history_client: AsyncClient) -> None:
"""An empty history returns items=[] and total=0."""
with patch(
"app.routers.history.history_service.list_history",
new=AsyncMock(
return_value=HistoryListResponse(items=[], total=0, page=1, page_size=100)
),
):
response = await history_client.get("/api/history")
body = response.json()
assert body["items"] == []
assert body["total"] == 0
# ---------------------------------------------------------------------------
# GET /api/history/{ip}
# ---------------------------------------------------------------------------
class TestIpHistory:
"""GET /api/history/{ip} — per-IP detail."""
async def test_returns_200_when_authenticated(
self, history_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200 for a known IP."""
with patch(
"app.routers.history.history_service.get_ip_detail",
new=AsyncMock(return_value=_make_ip_detail("1.2.3.4")),
):
response = await history_client.get("/api/history/1.2.3.4")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
self, client: AsyncClient
) -> None:
"""Unauthenticated request returns HTTP 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/history/1.2.3.4")
assert response.status_code == 401
async def test_returns_404_for_unknown_ip(
self, history_client: AsyncClient
) -> None:
"""Returns 404 when the IP has no records in the database."""
with patch(
"app.routers.history.history_service.get_ip_detail",
new=AsyncMock(return_value=None),
):
response = await history_client.get("/api/history/9.9.9.9")
assert response.status_code == 404
async def test_response_shape(self, history_client: AsyncClient) -> None:
"""Response body contains the expected keys and nested timeline."""
mock_detail = _make_ip_detail("1.2.3.4")
with patch(
"app.routers.history.history_service.get_ip_detail",
new=AsyncMock(return_value=mock_detail),
):
response = await history_client.get("/api/history/1.2.3.4")
body = response.json()
assert body["ip"] == "1.2.3.4"
assert body["total_bans"] == 2
assert body["total_failures"] == 10
assert body["country_code"] == "DE"
assert "timeline" in body
assert len(body["timeline"]) == 2
event = body["timeline"][0]
assert "jail" in event
assert "banned_at" in event
assert "ban_count" in event
assert "failures" in event
assert "matches" in event
async def test_aggregation_sums_failures(
self, history_client: AsyncClient
) -> None:
"""total_failures reflects the sum across all timeline events."""
mock_detail = _make_ip_detail("10.0.0.1")
mock_detail = IpDetailResponse(
ip="10.0.0.1",
total_bans=3,
total_failures=15,
last_ban_at="2026-03-01T10:00:00+00:00",
country_code=None,
country_name=None,
asn=None,
org=None,
timeline=[
IpTimelineEvent(
jail="sshd",
banned_at="2026-03-01T10:00:00+00:00",
ban_count=3,
failures=7,
matches=[],
),
IpTimelineEvent(
jail="sshd",
banned_at="2026-02-28T08:00:00+00:00",
ban_count=2,
failures=8,
matches=[],
),
],
)
with patch(
"app.routers.history.history_service.get_ip_detail",
new=AsyncMock(return_value=mock_detail),
):
response = await history_client.get("/api/history/10.0.0.1")
assert response.status_code == 200
body = response.json()
assert body["total_failures"] == 15
assert body["total_bans"] == 3

View File

@@ -0,0 +1,341 @@
"""Tests for history_service.list_history() and history_service.get_ip_detail()."""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
import aiosqlite
import pytest
from app.services import history_service
# ---------------------------------------------------------------------------
# Time helpers
# ---------------------------------------------------------------------------
_NOW: int = int(time.time())
_ONE_HOUR_AGO: int = _NOW - 3600
_TWO_DAYS_AGO: int = _NOW - 2 * 24 * 3600
_THIRTY_DAYS_AGO: int = _NOW - 30 * 24 * 3600
# ---------------------------------------------------------------------------
# DB fixture helpers
# ---------------------------------------------------------------------------
async def _create_f2b_db(path: str, rows: list[dict[str, Any]]) -> None:
"""Create a minimal fail2ban SQLite database with the given ban rows."""
async with aiosqlite.connect(path) as db:
await db.execute(
"CREATE TABLE jails ("
"name TEXT NOT NULL UNIQUE, "
"enabled INTEGER NOT NULL DEFAULT 1"
")"
)
await db.execute(
"CREATE TABLE bans ("
"jail TEXT NOT NULL, "
"ip TEXT, "
"timeofban INTEGER NOT NULL, "
"bantime INTEGER NOT NULL, "
"bancount INTEGER NOT NULL DEFAULT 1, "
"data JSON"
")"
)
for row in rows:
await db.execute(
"INSERT INTO bans (jail, ip, timeofban, bantime, bancount, data) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
row["jail"],
row["ip"],
row["timeofban"],
row.get("bantime", 3600),
row.get("bancount", 1),
json.dumps(row["data"]) if "data" in row else None,
),
)
await db.commit()
@pytest.fixture
async def f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc]
"""Return the path to a test fail2ban SQLite database."""
path = str(tmp_path / "fail2ban_test.sqlite3")
await _create_f2b_db(
path,
[
{
"jail": "sshd",
"ip": "1.2.3.4",
"timeofban": _ONE_HOUR_AGO,
"bantime": 3600,
"bancount": 3,
"data": {
"matches": ["Mar 1 sshd[1]: Failed password for root"],
"failures": 5,
},
},
{
"jail": "nginx",
"ip": "5.6.7.8",
"timeofban": _ONE_HOUR_AGO,
"bantime": 7200,
"bancount": 1,
"data": {"matches": ["GET /admin HTTP/1.1"], "failures": 3},
},
{
"jail": "sshd",
"ip": "1.2.3.4",
"timeofban": _TWO_DAYS_AGO,
"bantime": 3600,
"bancount": 2,
"data": {"failures": 5},
},
{
"jail": "sshd",
"ip": "9.0.0.1",
"timeofban": _THIRTY_DAYS_AGO,
"bantime": 3600,
"bancount": 1,
"data": None,
},
],
)
return path
# ---------------------------------------------------------------------------
# list_history tests
# ---------------------------------------------------------------------------
class TestListHistory:
"""history_service.list_history()."""
async def test_returns_all_bans_with_no_filter(
self, f2b_db_path: str
) -> None:
"""No filter returns every record in the database."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history("fake_socket")
assert result.total == 4
assert len(result.items) == 4
async def test_time_range_filter_excludes_old_bans(
self, f2b_db_path: str
) -> None:
"""The ``range_`` filter excludes bans older than the window."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
# "24h" window should include only the two recent bans
result = await history_service.list_history(
"fake_socket", range_="24h"
)
assert result.total == 2
async def test_jail_filter(self, f2b_db_path: str) -> None:
"""Jail filter restricts results to bans from that jail."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history("fake_socket", jail="nginx")
assert result.total == 1
assert result.items[0].jail == "nginx"
async def test_ip_prefix_filter(self, f2b_db_path: str) -> None:
"""IP prefix filter restricts results to matching IPs."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", ip_filter="1.2.3"
)
assert result.total == 2
for item in result.items:
assert item.ip.startswith("1.2.3")
async def test_combined_filters(self, f2b_db_path: str) -> None:
"""Jail + IP prefix filters applied together narrow the result set."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", jail="sshd", ip_filter="1.2.3.4"
)
# 2 sshd bans for 1.2.3.4
assert result.total == 2
async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None:
"""Filtering by a non-existent IP returns an empty result set."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", ip_filter="99.99.99.99"
)
assert result.total == 0
assert result.items == []
async def test_failures_extracted_from_data(
self, f2b_db_path: str
) -> None:
"""``failures`` field is parsed from the JSON ``data`` column."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", ip_filter="5.6.7.8"
)
assert result.total == 1
assert result.items[0].failures == 3
async def test_matches_extracted_from_data(
self, f2b_db_path: str
) -> None:
"""``matches`` list is parsed from the JSON ``data`` column."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", ip_filter="1.2.3.4", range_="24h"
)
# Most recent record for 1.2.3.4 has a matches list
recent = result.items[0]
assert len(recent.matches) == 1
assert "Failed password" in recent.matches[0]
async def test_null_data_column_handled_gracefully(
self, f2b_db_path: str
) -> None:
"""Records with ``data=NULL`` produce failures=0 and matches=[]."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", ip_filter="9.0.0.1"
)
assert result.total == 1
item = result.items[0]
assert item.failures == 0
assert item.matches == []
async def test_pagination(self, f2b_db_path: str) -> None:
"""Pagination returns the correct slice."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", page=1, page_size=2
)
assert result.total == 4
assert len(result.items) == 2
assert result.page == 1
assert result.page_size == 2
# ---------------------------------------------------------------------------
# get_ip_detail tests
# ---------------------------------------------------------------------------
class TestGetIpDetail:
"""history_service.get_ip_detail()."""
async def test_returns_none_for_unknown_ip(
self, f2b_db_path: str
) -> None:
"""Returns ``None`` when the IP has no records in the database."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.get_ip_detail("fake_socket", "99.99.99.99")
assert result is None
async def test_returns_ip_detail_for_known_ip(
self, f2b_db_path: str
) -> None:
"""Returns an IpDetailResponse with correct totals for a known IP."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
assert result is not None
assert result.ip == "1.2.3.4"
assert result.total_bans == 2
assert result.total_failures == 10 # 5 + 5
async def test_timeline_ordered_newest_first(
self, f2b_db_path: str
) -> None:
"""Timeline events are ordered newest-first."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
assert result is not None
assert len(result.timeline) == 2
# First event should be the most recent
assert result.timeline[0].banned_at > result.timeline[1].banned_at
async def test_last_ban_at_is_most_recent(self, f2b_db_path: str) -> None:
"""``last_ban_at`` matches the banned_at of the first timeline event."""
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
assert result is not None
assert result.last_ban_at == result.timeline[0].banned_at
async def test_geo_enrichment_applied_when_provided(
self, f2b_db_path: str
) -> None:
"""Geolocation is applied when a geo_enricher is provided."""
from app.services.geo_service import GeoInfo
mock_geo = GeoInfo(
country_code="US",
country_name="United States",
asn="AS15169",
org="Google",
)
fake_enricher = AsyncMock(return_value=mock_geo)
with patch(
"app.services.history_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.get_ip_detail(
"fake_socket", "1.2.3.4", geo_enricher=fake_enricher
)
assert result is not None
assert result.country_code == "US"
assert result.country_name == "United States"
assert result.asn == "AS15169"
assert result.org == "Google"

View File

@@ -0,0 +1,53 @@
/**
* API functions for the ban history endpoints.
*/
import { get } from "./client";
import { ENDPOINTS } from "./endpoints";
import type {
HistoryListResponse,
HistoryQuery,
IpDetailResponse,
} from "../types/history";
/**
* Fetch a paginated list of historical bans with optional filters.
*/
export async function fetchHistory(
query: HistoryQuery = {},
): Promise<HistoryListResponse> {
const params = new URLSearchParams();
if (query.range) params.set("range", query.range);
if (query.jail) params.set("jail", query.jail);
if (query.ip) params.set("ip", query.ip);
if (query.page !== undefined) params.set("page", String(query.page));
if (query.page_size !== undefined)
params.set("page_size", String(query.page_size));
const qs = params.toString();
const url = qs
? `${ENDPOINTS.history}?${qs}`
: ENDPOINTS.history;
return get<HistoryListResponse>(url);
}
/**
* Fetch the full ban history for a single IP address.
*
* @returns null when the server returns 404 (no history for this IP).
*/
export async function fetchIpHistory(ip: string): Promise<IpDetailResponse | null> {
try {
return await get<IpDetailResponse>(ENDPOINTS.historyIp(ip));
} catch (err: unknown) {
if (
typeof err === "object" &&
err !== null &&
"status" in err &&
(err as { status: number }).status === 404
) {
return null;
}
throw err;
}
}

View File

@@ -0,0 +1,111 @@
/**
* `useHistory` hook — fetches and manages ban history data.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchHistory, fetchIpHistory } from "../api/history";
import type {
HistoryBanItem,
HistoryQuery,
IpDetailResponse,
} from "../types/history";
// ---------------------------------------------------------------------------
// useHistory — paginated list
// ---------------------------------------------------------------------------
export interface UseHistoryResult {
items: HistoryBanItem[];
total: number;
page: number;
loading: boolean;
error: string | null;
setPage: (page: number) => void;
refresh: () => void;
}
export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
const [items, setItems] = useState<HistoryBanItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(query.page ?? 1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError(null);
fetchHistory({ ...query, page })
.then((resp) => {
setItems(resp.items);
setTotal(resp.total);
})
.catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
})
.finally((): void => {
setLoading(false);
});
}, [query, page]);
useEffect((): (() => void) => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { items, total, page, loading, error, setPage, refresh: load };
}
// ---------------------------------------------------------------------------
// useIpHistory — per-IP detail
// ---------------------------------------------------------------------------
export interface UseIpHistoryResult {
detail: IpDetailResponse | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
export function useIpHistory(ip: string): UseIpHistoryResult {
const [detail, setDetail] = useState<IpDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError(null);
fetchIpHistory(ip)
.then((resp) => {
setDetail(resp);
})
.catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
})
.finally((): void => {
setLoading(false);
});
}, [ip]);
useEffect((): (() => void) => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { detail, loading, error, refresh: load };
}

View File

@@ -1,23 +1,615 @@
/**
* Ban history placeholder page — full implementation in Stage 9.
* HistoryPage — forensic exploration of all historical fail2ban ban records.
*
* Shows a paginated, filterable table of every ban ever recorded in the
* fail2ban database. Clicking an IP address opens a per-IP timeline view.
* Rows with repeatedly-banned IPs are highlighted in amber.
*/
import { Text, makeStyles, tokens } from "@fluentui/react-components";
import { useCallback, useState } from "react";
import {
Badge,
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Input,
MessageBar,
MessageBarBody,
Select,
Spinner,
Table,
TableBody,
TableCell,
TableCellLayout,
TableColumnDefinition,
TableHeader,
TableHeaderCell,
TableRow,
Text,
Toolbar,
ToolbarButton,
createTableColumn,
makeStyles,
tokens,
} from "@fluentui/react-components";
import {
ArrowCounterclockwiseRegular,
ArrowLeftRegular,
ChevronLeftRegular,
ChevronRightRegular,
} from "@fluentui/react-icons";
import { useHistory, useIpHistory } from "../hooks/useHistory";
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Ban counts at or above this threshold are highlighted. */
const HIGH_BAN_THRESHOLD = 5;
const PAGE_SIZE = 50;
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
{ label: "Last 24 hours", value: "24h" },
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
{ label: "Last 365 days", value: "365d" },
];
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: { padding: tokens.spacingVerticalXXL },
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalL,
padding: tokens.spacingVerticalXXL,
paddingLeft: tokens.spacingHorizontalXXL,
paddingRight: tokens.spacingHorizontalXXL,
},
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: tokens.spacingHorizontalM,
},
filterRow: {
display: "flex",
alignItems: "flex-end",
gap: tokens.spacingHorizontalM,
flexWrap: "wrap",
},
filterLabel: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
tableWrapper: {
overflow: "auto",
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
},
ipCell: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: "0.85rem",
color: tokens.colorBrandForeground1,
cursor: "pointer",
textDecoration: "underline",
},
highBanRow: {
backgroundColor: tokens.colorPaletteYellowBackground1,
},
pagination: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
justifyContent: "flex-end",
},
detailGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: tokens.spacingVerticalM,
padding: tokens.spacingVerticalM,
background: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
marginBottom: tokens.spacingVerticalM,
},
detailField: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
detailLabel: {
color: tokens.colorNeutralForeground3,
fontSize: "0.75rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
},
detailValue: {
fontSize: "0.9rem",
fontWeight: "600",
},
monoText: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: "0.85rem",
},
});
export function HistoryPage(): React.JSX.Element {
// ---------------------------------------------------------------------------
// Column definitions for the main history table
// ---------------------------------------------------------------------------
const HISTORY_COLUMNS = (
onClickIp: (ip: string) => void,
styles: ReturnType<typeof useStyles>,
): TableColumnDefinition<HistoryBanItem>[] =>
[
createTableColumn<HistoryBanItem>({
columnId: "banned_at",
renderHeaderCell: () => "Banned At",
renderCell: (item) => (
<Text size={200}>{new Date(item.banned_at).toLocaleString()}</Text>
),
}),
createTableColumn<HistoryBanItem>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: (item) => (
<span
className={styles.ipCell}
role="button"
tabIndex={0}
onClick={(): void => {
onClickIp(item.ip);
}}
onKeyDown={(e): void => {
if (e.key === "Enter" || e.key === " ") onClickIp(item.ip);
}}
>
{item.ip}
</span>
),
}),
createTableColumn<HistoryBanItem>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
}),
createTableColumn<HistoryBanItem>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (item) => (
<Text size={200}>{item.country_name ?? item.country_code ?? "—"}</Text>
),
}),
createTableColumn<HistoryBanItem>({
columnId: "failures",
renderHeaderCell: () => "Failures",
renderCell: (item) => <Text size={200}>{String(item.failures)}</Text>,
}),
createTableColumn<HistoryBanItem>({
columnId: "ban_count",
renderHeaderCell: () => "Times Banned",
renderCell: (item) => (
<Badge
appearance="filled"
color={item.ban_count >= HIGH_BAN_THRESHOLD ? "danger" : "subtle"}
size="medium"
>
{String(item.ban_count)}
</Badge>
),
}),
] as ReturnType<typeof createTableColumn<HistoryBanItem>>[];
// ---------------------------------------------------------------------------
// IpDetailView — per-IP detail view
// ---------------------------------------------------------------------------
interface IpDetailViewProps {
ip: string;
onBack: () => void;
}
function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element {
const styles = useStyles();
const { detail, loading, error, refresh } = useIpHistory(ip);
if (loading) {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}
>
<Spinner label={`Loading history for ${ip}`} />
</div>
);
}
if (error) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (!detail) {
return (
<MessageBar intent="warning">
<MessageBarBody>No history found for {ip}.</MessageBarBody>
</MessageBar>
);
}
return (
<div className={styles.root}>
<Text as="h1" size={700} weight="semibold">
History
</Text>
<Text as="p" size={300}>
Historical ban query view will be implemented in Stage 9.
<div style={{ display: "flex", flexDirection: "column", gap: tokens.spacingVerticalL }}>
{/* Back button + heading */}
<div className={styles.header}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
<Button
icon={<ArrowLeftRegular />}
appearance="subtle"
onClick={onBack}
>
Back to list
</Button>
<Text as="h2" size={600} weight="semibold" className={styles.monoText}>
{ip}
</Text>
</div>
<Button
icon={<ArrowCounterclockwiseRegular />}
appearance="subtle"
onClick={(): void => {
refresh();
}}
>
Refresh
</Button>
</div>
{/* Summary grid */}
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Total Bans</span>
<span className={styles.detailValue}>{String(detail.total_bans)}</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Total Failures</span>
<span className={styles.detailValue}>{String(detail.total_failures)}</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Last Banned</span>
<span className={styles.detailValue}>
{detail.last_ban_at
? new Date(detail.last_ban_at).toLocaleString()
: "—"}
</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Country</span>
<span className={styles.detailValue}>
{detail.country_name ?? detail.country_code ?? "—"}
</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>ASN</span>
<span className={styles.detailValue}>{detail.asn ?? "—"}</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Organisation</span>
<span className={styles.detailValue}>{detail.org ?? "—"}</span>
</div>
</div>
{/* Timeline table */}
<Text weight="semibold" size={400}>
Ban Timeline ({String(detail.timeline.length)} events)
</Text>
<div className={styles.tableWrapper}>
<Table size="small" aria-label="Ban timeline">
<TableHeader>
<TableRow>
<TableHeaderCell>Banned At</TableHeaderCell>
<TableHeaderCell>Jail</TableHeaderCell>
<TableHeaderCell>Failures</TableHeaderCell>
<TableHeaderCell>Times Banned</TableHeaderCell>
<TableHeaderCell>Matched Lines</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{detail.timeline.map((event) => (
<TableRow key={`${event.jail}-${event.banned_at}`}>
<TableCell>
<TableCellLayout>
{new Date(event.banned_at).toLocaleString()}
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{event.jail}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{String(event.failures)}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{String(event.ban_count)}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
{event.matches.length === 0 ? (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
</Text>
) : (
<Text
size={100}
style={{
fontFamily: "Consolas, 'Courier New', monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{event.matches.join("\n")}
</Text>
)}
</TableCellLayout>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// HistoryPage — main component
// ---------------------------------------------------------------------------
export function HistoryPage(): React.JSX.Element {
const styles = useStyles();
// Filter state
const [range, setRange] = useState<TimeRange | undefined>(undefined);
const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState("");
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
page_size: PAGE_SIZE,
});
// Per-IP detail navigation
const [selectedIp, setSelectedIp] = useState<string | null>(null);
const { items, total, page, loading, error, setPage, refresh } =
useHistory(appliedQuery);
const applyFilters = useCallback((): void => {
setAppliedQuery({
range: range,
jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined,
page_size: PAGE_SIZE,
});
}, [range, jailFilter, ipFilter]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
/** History table columns with IP click handler. */
const columns = HISTORY_COLUMNS(
(ip: string): void => {
setSelectedIp(ip);
},
styles,
);
// If an IP is selected, show the detail view.
if (selectedIp !== null) {
return (
<div className={styles.root}>
<IpDetailView
ip={selectedIp}
onBack={(): void => {
setSelectedIp(null);
}}
/>
</div>
);
}
return (
<div className={styles.root}>
{/* ---------------------------------------------------------------- */}
{/* Header */}
{/* ---------------------------------------------------------------- */}
<div className={styles.header}>
<Text as="h1" size={700} weight="semibold">
History
</Text>
<Toolbar size="small">
<ToolbarButton
icon={<ArrowCounterclockwiseRegular />}
onClick={(): void => {
refresh();
}}
disabled={loading}
title="Refresh"
/>
</Toolbar>
</div>
{/* ---------------------------------------------------------------- */}
{/* Filter bar */}
{/* ---------------------------------------------------------------- */}
<div className={styles.filterRow}>
<div className={styles.filterLabel}>
<Text size={200}>Time range</Text>
<Select
aria-label="Time range"
value={range ?? ""}
onChange={(_ev, data): void => {
setRange(data.value === "" ? undefined : (data.value as TimeRange));
}}
size="small"
>
<option value="">All time</option>
{TIME_RANGE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</div>
<div className={styles.filterLabel}>
<Text size={200}>Jail</Text>
<Input
placeholder="e.g. sshd"
value={jailFilter}
onChange={(_ev, data): void => {
setJailFilter(data.value);
}}
size="small"
/>
</div>
<div className={styles.filterLabel}>
<Text size={200}>IP Address</Text>
<Input
placeholder="e.g. 192.168"
value={ipFilter}
onChange={(_ev, data): void => {
setIpFilter(data.value);
}}
size="small"
onKeyDown={(e): void => {
if (e.key === "Enter") applyFilters();
}}
/>
</div>
<Button appearance="primary" size="small" onClick={applyFilters}>
Apply
</Button>
<Button
appearance="subtle"
size="small"
onClick={(): void => {
setRange(undefined);
setJailFilter("");
setIpFilter("");
setAppliedQuery({ page_size: PAGE_SIZE });
}}
>
Clear
</Button>
</div>
{/* ---------------------------------------------------------------- */}
{/* Error / loading state */}
{/* ---------------------------------------------------------------- */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{loading && !error && (
<div
style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}
>
<Spinner label="Loading history…" />
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* Summary */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
{String(total)} record{total !== 1 ? "s" : ""} found ·
Page {String(page)} of {String(totalPages)} ·
Rows highlighted in yellow have {String(HIGH_BAN_THRESHOLD)}+ repeat bans
</Text>
)}
{/* ---------------------------------------------------------------- */}
{/* DataGrid table */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<div className={styles.tableWrapper}>
<DataGrid
items={items}
columns={columns}
getRowId={(item: HistoryBanItem) => `${item.ip}-${item.banned_at}`}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<HistoryBanItem>>
{({ item }) => (
<DataGridRow<HistoryBanItem>
key={`${item.ip}-${item.banned_at}`}
className={
item.ban_count >= HIGH_BAN_THRESHOLD
? styles.highBanRow
: undefined
}
>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* Pagination */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && totalPages > 1 && (
<div className={styles.pagination}>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
size="small"
disabled={page <= 1}
onClick={(): void => {
setPage(page - 1);
}}
/>
<Text size={200}>
Page {String(page)} / {String(totalPages)}
</Text>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
size="small"
disabled={page >= totalPages}
onClick={(): void => {
setPage(page + 1);
}}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
/**
* TypeScript types for the ban history API.
*/
/** Optional time-range filter for history queries. */
export type TimeRange = "24h" | "7d" | "30d" | "365d";
/** A single row in the history ban-list table. */
export interface HistoryBanItem {
ip: string;
jail: string;
banned_at: string;
ban_count: number;
failures: number;
matches: string[];
country_code: string | null;
country_name: string | null;
asn: string | null;
org: string | null;
}
/** Paginated response from GET /api/history */
export interface HistoryListResponse {
items: HistoryBanItem[];
total: number;
page: number;
page_size: number;
}
/** A single ban event in a per-IP timeline. */
export interface IpTimelineEvent {
jail: string;
banned_at: string;
ban_count: number;
failures: number;
matches: string[];
}
/** Full historical record for a single IP address. */
export interface IpDetailResponse {
ip: string;
total_bans: number;
total_failures: number;
last_ban_at: string | null;
country_code: string | null;
country_name: string | null;
asn: string | null;
org: string | null;
timeline: IpTimelineEvent[];
}
/** Query parameters supported by GET /api/history */
export interface HistoryQuery {
range?: TimeRange;
jail?: string;
ip?: string;
page?: number;
page_size?: number;
}