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:
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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):
|
||||
|
||||
269
backend/app/services/history_service.py
Normal file
269
backend/app/services/history_service.py
Normal 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,
|
||||
)
|
||||
333
backend/tests/test_routers/test_history.py
Normal file
333
backend/tests/test_routers/test_history.py
Normal 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
|
||||
341
backend/tests/test_services/test_history_service.py
Normal file
341
backend/tests/test_services/test_history_service.py
Normal 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"
|
||||
53
frontend/src/api/history.ts
Normal file
53
frontend/src/api/history.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
111
frontend/src/hooks/useHistory.ts
Normal file
111
frontend/src/hooks/useHistory.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
59
frontend/src/types/history.ts
Normal file
59
frontend/src/types/history.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user