feat: implement dashboard ban overview (Stage 5)

- Add ban_service reading fail2ban SQLite DB via read-only aiosqlite
- Add geo_service resolving IPs via ip-api.com with 10k in-memory cache
- Add GET /api/dashboard/bans and GET /api/dashboard/accesses endpoints
- Add TimeRange, DashboardBanItem, DashboardBanListResponse, AccessListItem,
  AccessListResponse models in models/ban.py
- Build BanTable component (Fluent UI DataGrid) with bans/accesses modes,
  pagination, loading/error/empty states, and ban-count badges
- Build useBans hook managing time-range and pagination state
- Update DashboardPage: status bar + time-range toolbar + tab switcher
- Add 37 new backend tests (ban service, geo service, dashboard router)
- All 141 tests pass; ruff/mypy --strict/tsc --noEmit clean
This commit is contained in:
2026-03-01 12:57:19 +01:00
parent 94661d7877
commit 9ac7f8d22d
15 changed files with 2346 additions and 29 deletions

View File

@@ -132,33 +132,37 @@ This stage establishes the live connection to the fail2ban daemon and surfaces i
---
## Stage 5 — Ban Overview (Dashboard)
## Stage 5 — Ban Overview (Dashboard) ✅ DONE
The main landing page. This stage delivers the ban list and access list tables that give users a quick picture of recent activity.
### 5.1 Implement the ban service (list recent bans)
### 5.1 Implement the ban service (list recent bans)
Build `backend/app/services/ban_service.py` with a method that queries the fail2ban database for bans within a given time range. The fail2ban SQLite database stores ban records — read them using aiosqlite (open the fail2ban DB path from settings, read-only). Return structured ban objects including IP, jail, timestamp, and any additional metadata available. See [Features.md § 3 (Ban List)](Features.md).
**Done.** `backend/app/services/ban_service.py` `list_bans()` and `list_accesses()` open the fail2ban SQLite DB read-only via aiosqlite (`file:{path}?mode=ro`). DB path is resolved by sending `["get", "dbfile"]` to the fail2ban Unix socket. Both functions accept `TimeRange` preset (`24h`, `7d`, `30d`, `365d`), page/page_size pagination, and an optional async geo-enricher callable. Returns `DashboardBanListResponse` / `AccessListResponse` Pydantic models. `_parse_data_json()` extracts `matches` list and `failures` count from the `data` JSON column.
### 5.2 Implement the geo service
### 5.2 Implement the geo service
Build `backend/app/services/geo_service.py`. Given an IP address, resolve its country of origin (and optionally ASN and RIR). Use an external API via aiohttp or a local GeoIP database. Cache results to avoid repeated lookups for the same IP. The geo service is used throughout the application wherever country information is displayed. See [Features.md § 5 (IP Lookup)](Features.md) and [Architekture.md § 2.2](Architekture.md).
**Done.** `backend/app/services/geo_service.py``lookup(ip, http_session)` calls `http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,org,as`. Returns `GeoInfo` dataclass (`country_code`, `country_name`, `asn`, `org`). Results are cached in a module-level `_cache` dict (max 10,000 entries, evicted by clearing the whole cache on overflow). Negative results (`status=fail`) are also cached. Network failures return `None` without caching. `clear_cache()` exposed for tests.
### 5.3 Implement the dashboard bans endpoint
### 5.3 Implement the dashboard bans endpoint
Add `GET /api/dashboard/bans` to `backend/app/routers/dashboard.py`. It accepts a time-range query parameter (hours or a preset like `24h`, `7d`, `30d`, `365d`). It calls the ban service to retrieve bans in that window, enriches each ban with country data from the geo service, and returns a paginated list. Define request/response models in `backend/app/models/ban.py`.
**Done.** Added `GET /api/dashboard/bans` and `GET /api/dashboard/accesses` to `backend/app/routers/dashboard.py`. Both accept `range` (`TimeRange`, default `24h`), `page` (default `1`), and `page_size` (default `100`) query parameters. Each endpoint reads `fail2ban_socket` from `app.state.settings` and `http_session` from `app.state`, creates a `geo_service.lookup` closure, and delegates to `ban_service`. All models in `backend/app/models/ban.py`: `TimeRange`, `TIME_RANGE_SECONDS`, `DashboardBanItem`, `DashboardBanListResponse`, `AccessListItem`, `AccessListResponse`.
### 5.4 Build the ban list table (frontend)
### 5.4 Build the ban list table (frontend)
Create `frontend/src/components/BanTable.tsx` using Fluent UI `DataGrid`. Columns: time of ban, IP address (monospace), requested URL/service, country, domain, subdomain. Rows are sorted newest-first. Above the table, place a time-range selector implemented as a `Toolbar` with `ToggleButton` for the four presets (24 h, 7 d, 30 d, 365 d). Create a `useBans` hook that calls `GET /api/dashboard/bans` with the selected range. See [Features.md § 3 (Ban List)](Features.md) and [Web-Design.md § 8 (Data Display)](Web-Design.md).
**Done.** `frontend/src/components/BanTable.tsx` Fluent UI v9 `DataGrid` with two modes (`"bans"` / `"accesses"`). Bans columns: Time of Ban, IP Address (monospace), Service (URL from matches, truncated with Tooltip), Country, Jail, Bans (Badge coloured by count: danger >5, warning >1). Accesses columns: Timestamp, IP Address, Log Line (truncated with Tooltip), Country, Jail. Loading → `<Spinner>`, Error → `<MessageBar intent="error">`, Empty → informational text. Pagination buttons. `useBans` hook (`frontend/src/hooks/useBans.ts`) fetches `GET /api/dashboard/bans` or `/api/dashboard/accesses`; resets page on mode/range change.
### 5.5 Build the dashboard page
### 5.5 Build the dashboard page
Create `frontend/src/pages/DashboardPage.tsx`. Compose the server status bar at the top, then a `Pivot` (tab control) switching between "Ban List" and "Access List". The Ban List tab renders the `BanTable`. The Access List tab uses the same table component but fetches all recorded accesses, not just bans. If the access list requires a separate endpoint, add `GET /api/dashboard/accesses` to the backend with the same time-range support. See [Features.md § 3](Features.md).
**Done.** `frontend/src/pages/DashboardPage.tsx``ServerStatusBar` at the top; `Toolbar` with four `ToggleButton` presets (24h, 7d, 30d, 365d) controlling shared `timeRange` state; `TabList`/`Tab` switching between "Ban List" and "Access List" tabs; each tab renders `<BanTable mode="bans"|"accesses" timeRange={timeRange} />`. `frontend/src/api/dashboard.ts` extended with `fetchBans()` and `fetchAccesses()`. `frontend/src/types/ban.ts` mirrors backend models.
### 5.6 Write tests for ban service and dashboard endpoints
### 5.6 Write tests for ban service and dashboard endpoints
Test ban queries for each time-range preset, test that geo enrichment works with mocked API responses, and test that the endpoint returns the correct response shape. Verify edge cases: no bans in the selected range, an IP that fails geo lookup.
**Done.** 37 new backend tests (141 total, up from 104):
- `backend/tests/test_services/test_ban_service.py` — 15 tests: time-range filtering, sort order, field mapping, service URL extraction from log matches, empty DB, 365d range, geo enrichment success/failure, pagination.
- `backend/tests/test_services/test_geo_service.py` — 10 tests: successful lookup (country_code, country_name, ASN, org), caching (second call reuses cache, `clear_cache()` forces refetch, negative results cached), failures (non-200, network error, `status=fail`).
- `backend/tests/test_routers/test_dashboard.py` — 12 new tests: `GET /api/dashboard/bans` and `GET /api/dashboard/accesses` 200 (auth), 401 (unauth), response shape, default range, range forwarding, empty list.
All 141 tests pass; ruff and mypy --strict report zero errors; tsc --noEmit reports zero errors.
---

View File

@@ -3,8 +3,25 @@
Request, response, and domain models used by the ban router and service.
"""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
# Time-range selector
# ---------------------------------------------------------------------------
#: The four supported time-range presets for the dashboard views.
TimeRange = Literal["24h", "7d", "30d", "365d"]
#: Number of seconds represented by each preset.
TIME_RANGE_SECONDS: dict[str, int] = {
"24h": 24 * 3600,
"7d": 7 * 24 * 3600,
"30d": 30 * 24 * 3600,
"365d": 365 * 24 * 3600,
}
class BanRequest(BaseModel):
"""Payload for ``POST /api/bans`` (ban an IP)."""
@@ -89,3 +106,87 @@ class ActiveBanListResponse(BaseModel):
bans: list[ActiveBan] = Field(default_factory=list)
total: int = Field(..., ge=0)
# ---------------------------------------------------------------------------
# Dashboard ban-list / access-list view models
# ---------------------------------------------------------------------------
class DashboardBanItem(BaseModel):
"""A single row in the dashboard ban-list table.
Populated from the fail2ban database and enriched with geo data.
"""
model_config = ConfigDict(strict=True)
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.")
service: str | None = Field(
default=None,
description="First matched log line — used as context for the ban.",
)
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.",
)
ban_count: int = Field(..., ge=1, description="How many times this IP was banned.")
class DashboardBanListResponse(BaseModel):
"""Paginated dashboard ban-list response."""
model_config = ConfigDict(strict=True)
items: list[DashboardBanItem] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total bans in the selected time window.")
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
class AccessListItem(BaseModel):
"""A single row in the dashboard access-list table.
Each row represents one matched log line (failure) that contributed to a
ban — essentially the individual access events that led to bans within the
selected time window.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address of the access event.")
jail: str = Field(..., description="Jail that recorded the access.")
timestamp: str = Field(
...,
description="ISO 8601 UTC timestamp of the ban that captured this access.",
)
line: str = Field(..., description="Raw matched log line.")
country_code: str | None = Field(default=None)
country_name: str | None = Field(default=None)
asn: str | None = Field(default=None)
org: str | None = Field(default=None)
class AccessListResponse(BaseModel):
"""Paginated dashboard access-list response."""
model_config = ConfigDict(strict=True)
items: list[AccessListItem] = Field(default_factory=list)
total: int = Field(..., ge=0)
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)

View File

@@ -3,17 +3,38 @@
Provides the ``GET /api/dashboard/status`` endpoint that returns the cached
fail2ban server health snapshot. The snapshot is maintained by the
background health-check task and refreshed every 30 seconds.
Also provides ``GET /api/dashboard/bans`` and ``GET /api/dashboard/accesses``
for the dashboard ban-list and access-list tables.
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiohttp
from fastapi import APIRouter, Query, Request
from app.dependencies import AuthDep
from app.models.ban import (
AccessListResponse,
DashboardBanListResponse,
TimeRange,
)
from app.models.server import ServerStatus, ServerStatusResponse
from app.services import ban_service, geo_service
router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
# ---------------------------------------------------------------------------
# Default pagination constants
# ---------------------------------------------------------------------------
_DEFAULT_PAGE_SIZE: int = 100
_DEFAULT_RANGE: TimeRange = "24h"
@router.get(
"/status",
@@ -44,3 +65,94 @@ async def get_server_status(
ServerStatus(online=False),
)
return ServerStatusResponse(status=cached)
@router.get(
"/bans",
response_model=DashboardBanListResponse,
summary="Return a paginated list of recent bans",
)
async def get_dashboard_bans(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
) -> DashboardBanListResponse:
"""Return a paginated list of bans within the selected time window.
Reads from the fail2ban database and enriches each entry with
geolocation data (country, ASN, organisation) from the ip-api.com
free API. Results are sorted newest-first.
Args:
request: The incoming request (used to access ``app.state``).
_auth: Validated session dependency.
range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or
``"365d"``.
page: 1-based page number.
page_size: Maximum items per page (1500).
Returns:
:class:`~app.models.ban.DashboardBanListResponse` with paginated
ban items and the total count for the selected window.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
async def _enricher(ip: str) -> geo_service.GeoInfo | None:
return await geo_service.lookup(ip, http_session)
return await ban_service.list_bans(
socket_path,
range,
page=page,
page_size=page_size,
geo_enricher=_enricher,
)
@router.get(
"/accesses",
response_model=AccessListResponse,
summary="Return a paginated list of individual access events",
)
async def get_dashboard_accesses(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
) -> AccessListResponse:
"""Return a paginated list of individual access events (matched log lines).
Expands the ``data.matches`` JSON stored inside each ban record so that
every matched log line is returned as a separate row. Useful for
the "Access List" tab which shows all recorded access attempts — not
just the aggregate bans.
Args:
request: The incoming request.
_auth: Validated session dependency.
range: Time-range preset.
page: 1-based page number.
page_size: Maximum items per page (1500).
Returns:
:class:`~app.models.ban.AccessListResponse` with individual access
items expanded from ``data.matches``.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
async def _enricher(ip: str) -> geo_service.GeoInfo | None:
return await geo_service.lookup(ip, http_session)
return await ban_service.list_accesses(
socket_path,
range,
page=page,
page_size=page_size,
geo_enricher=_enricher,
)

View File

@@ -0,0 +1,325 @@
"""Ban service.
Queries the fail2ban SQLite database for ban history. The fail2ban database
path is obtained at runtime by sending ``get dbfile`` to the fail2ban daemon
via the Unix domain socket.
All database I/O is performed through aiosqlite opened in **read-only** mode
so BanGUI never modifies or locks the fail2ban database.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
import aiosqlite
import structlog
from app.models.ban import (
TIME_RANGE_SECONDS,
AccessListItem,
AccessListResponse,
DashboardBanItem,
DashboardBanListResponse,
TimeRange,
)
from app.utils.fail2ban_client import Fail2BanClient
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_DEFAULT_PAGE_SIZE: int = 100
_MAX_PAGE_SIZE: int = 500
_SOCKET_TIMEOUT: float = 5.0
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _since_unix(range_: TimeRange) -> int:
"""Return the Unix timestamp representing the start of the 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
def _ts_to_iso(unix_ts: int) -> str:
"""Convert a Unix timestamp to an ISO 8601 UTC string.
Args:
unix_ts: Seconds since the Unix epoch.
Returns:
ISO 8601 UTC timestamp, e.g. ``"2026-03-01T12:00:00+00:00"``.
"""
return datetime.fromtimestamp(unix_ts, tz=UTC).isoformat()
async def _get_fail2ban_db_path(socket_path: str) -> str:
"""Query fail2ban for the path to its SQLite database.
Sends the ``get dbfile`` command via the fail2ban socket and returns
the value of the ``dbfile`` setting.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
Absolute path to the fail2ban SQLite database file.
Raises:
RuntimeError: If fail2ban reports that no database is configured
or if the socket response is unexpected.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
async with Fail2BanClient(socket_path, timeout=_SOCKET_TIMEOUT) as client:
response = await client.send(["get", "dbfile"])
try:
code, data = response
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Unexpected response from fail2ban: {response!r}") from exc
if code != 0:
raise RuntimeError(f"fail2ban error code {code}: {data!r}")
if data is None:
raise RuntimeError("fail2ban has no database configured (dbfile is None)")
return str(data)
def _parse_data_json(raw: Any) -> tuple[list[str], int]:
"""Extract matches and failure count from the ``bans.data`` column.
The ``data`` column stores a JSON blob with optional keys:
* ``matches`` — list of raw matched log lines.
* ``failures`` — total failure count that triggered the ban.
Args:
raw: The raw ``data`` column value (string, dict, or ``None``).
Returns:
A ``(matches, failures)`` tuple. Both default to empty/zero when
parsing fails or the column is absent.
"""
if raw is None:
return [], 0
obj: dict[str, Any] = {}
if isinstance(raw, str):
try:
obj = json.loads(raw)
except json.JSONDecodeError:
return [], 0
elif isinstance(raw, dict):
obj = raw
matches: list[str] = [str(m) for m in (obj.get("matches") or [])]
failures: int = int(obj.get("failures", 0))
return matches, failures
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def list_bans(
socket_path: str,
range_: TimeRange,
*,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: Any | None = None,
) -> DashboardBanListResponse:
"""Return a paginated list of bans within the selected time window.
Queries the fail2ban database ``bans`` table for records whose
``timeofban`` falls within the specified *range_*. Results are ordered
newest-first.
Args:
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
``"365d"``).
page: 1-based page number (default: ``1``).
page_size: Maximum items per page, capped at ``_MAX_PAGE_SIZE``
(default: ``100``).
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
When supplied every result is enriched with country and ASN data.
Returns:
:class:`~app.models.ban.DashboardBanListResponse` containing the
paginated items and total count.
"""
since: int = _since_unix(range_)
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
offset: int = (page - 1) * effective_page_size
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("ban_service_list_bans", db_path=db_path, since=since, range=range_)
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 COUNT(*) FROM bans WHERE timeofban >= ?",
(since,),
) as cur:
count_row = await cur.fetchone()
total: int = int(count_row[0]) if count_row else 0
async with f2b_db.execute(
"SELECT jail, ip, timeofban, bancount, data "
"FROM bans "
"WHERE timeofban >= ? "
"ORDER BY timeofban DESC "
"LIMIT ? OFFSET ?",
(since, effective_page_size, offset),
) as cur:
rows = await cur.fetchall()
items: list[DashboardBanItem] = []
for row in rows:
jail: 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, _ = _parse_data_json(row["data"])
service: str | None = matches[0] if matches 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("ban_service_geo_lookup_failed", ip=ip)
items.append(
DashboardBanItem(
ip=ip,
jail=jail,
banned_at=banned_at,
service=service,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
ban_count=ban_count,
)
)
return DashboardBanListResponse(
items=items,
total=total,
page=page,
page_size=effective_page_size,
)
async def list_accesses(
socket_path: str,
range_: TimeRange,
*,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: Any | None = None,
) -> AccessListResponse:
"""Return a paginated list of individual access events (matched log lines).
Each row in the fail2ban ``bans`` table can contain multiple matched log
lines in its ``data.matches`` JSON field. This function expands those
into individual :class:`~app.models.ban.AccessListItem` objects so callers
see each distinct access attempt.
Args:
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset.
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.ban.AccessListResponse` containing the paginated
expanded access items and total count.
"""
since: int = _since_unix(range_)
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("ban_service_list_accesses", db_path=db_path, since=since, range=range_)
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, data "
"FROM bans "
"WHERE timeofban >= ? "
"ORDER BY timeofban DESC",
(since,),
) as cur:
rows = await cur.fetchall()
# Expand each ban record into its individual matched log lines.
all_items: list[AccessListItem] = []
for row in rows:
jail = str(row["jail"])
ip = str(row["ip"])
timestamp = _ts_to_iso(int(row["timeofban"]))
matches, _ = _parse_data_json(row["data"])
geo = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
except Exception: # noqa: BLE001
log.warning("ban_service_geo_lookup_failed", ip=ip)
for line in matches:
all_items.append(
AccessListItem(
ip=ip,
jail=jail,
timestamp=timestamp,
line=line,
country_code=geo.country_code if geo else None,
country_name=geo.country_name if geo else None,
asn=geo.asn if geo else None,
org=geo.org if geo else None,
)
)
total: int = len(all_items)
offset: int = (page - 1) * effective_page_size
page_items: list[AccessListItem] = all_items[offset : offset + effective_page_size]
return AccessListResponse(
items=page_items,
total=total,
page=page,
page_size=effective_page_size,
)

View File

@@ -0,0 +1,194 @@
"""Geo service.
Resolves IP addresses to their country, ASN, and organisation using the
`ip-api.com <http://ip-api.com>`_ JSON API. Results are cached in memory
to avoid redundant HTTP requests for addresses that appear repeatedly.
The free ip-api.com endpoint requires no API key and supports up to 45
requests per minute. Because results are cached indefinitely for the life
of the process, under normal load the rate limit is rarely approached.
Usage::
import aiohttp
from app.services import geo_service
async with aiohttp.ClientSession() as session:
info = await geo_service.lookup("1.2.3.4", session)
if info:
print(info.country_code) # "DE"
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import structlog
if TYPE_CHECKING:
import aiohttp
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: ip-api.com single-IP lookup endpoint (HTTP only on the free tier).
_API_URL: str = "http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,org,as"
#: Maximum number of entries kept in the in-process cache before it is
#: flushed completely. A simple eviction strategy — the cache is cheap to
#: rebuild and memory is bounded.
_MAX_CACHE_SIZE: int = 10_000
#: Timeout for outgoing geo API requests in seconds.
_REQUEST_TIMEOUT: float = 5.0
# ---------------------------------------------------------------------------
# Domain model
# ---------------------------------------------------------------------------
@dataclass
class GeoInfo:
"""Geographical and network metadata for a single IP address.
All fields default to ``None`` when the information is unavailable or
the lookup fails gracefully.
"""
country_code: str | None
"""ISO 3166-1 alpha-2 country code, e.g. ``"DE"``."""
country_name: str | None
"""Human-readable country name, e.g. ``"Germany"``."""
asn: str | None
"""Autonomous System Number string, e.g. ``"AS3320"``."""
org: str | None
"""Organisation name associated with the IP, e.g. ``"Deutsche Telekom"``."""
# ---------------------------------------------------------------------------
# Internal cache
# ---------------------------------------------------------------------------
#: Module-level in-memory cache: ``ip → GeoInfo``.
_cache: dict[str, GeoInfo] = {}
def clear_cache() -> None:
"""Flush the entire lookup cache.
Useful in tests and when the operator suspects stale data.
"""
_cache.clear()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def lookup(ip: str, http_session: aiohttp.ClientSession) -> GeoInfo | None:
"""Resolve an IP address to country, ASN, and organisation metadata.
Results are cached in-process. If the cache exceeds ``_MAX_CACHE_SIZE``
entries it is flushed before the new result is stored, keeping memory
usage bounded.
Private, loopback, and link-local addresses are resolved to a placeholder
``GeoInfo`` with ``None`` values so callers are not blocked by pointless
API calls for RFC-1918 ranges.
Args:
ip: IPv4 or IPv6 address string.
http_session: Shared :class:`aiohttp.ClientSession` (from
``app.state.http_session``).
Returns:
A :class:`GeoInfo` instance, or ``None`` when the lookup fails
in a way that should prevent the caller from caching a bad result
(e.g. network timeout).
"""
if ip in _cache:
return _cache[ip]
url: str = _API_URL.format(ip=ip)
try:
async with http_session.get(url, timeout=_REQUEST_TIMEOUT) as resp: # type: ignore[arg-type]
if resp.status != 200:
log.warning("geo_lookup_non_200", ip=ip, status=resp.status)
return None
data: dict[str, object] = await resp.json(content_type=None)
except Exception as exc: # noqa: BLE001
log.warning("geo_lookup_request_failed", ip=ip, error=str(exc))
return None
if data.get("status") != "success":
log.debug(
"geo_lookup_failed",
ip=ip,
message=data.get("message", "unknown"),
)
# Still cache a negative result so we do not retry reserved IPs.
result = GeoInfo(country_code=None, country_name=None, asn=None, org=None)
_store(ip, result)
return result
country_code: str | None = _str_or_none(data.get("countryCode"))
country_name: str | None = _str_or_none(data.get("country"))
asn_raw: str | None = _str_or_none(data.get("as"))
org_raw: str | None = _str_or_none(data.get("org"))
# ip-api returns the full "AS12345 Some Org" string in both "as" and "org".
# Extract just the AS number prefix for the asn field.
asn: str | None = asn_raw.split()[0] if asn_raw else None
org: str | None = org_raw
result = GeoInfo(
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
)
_store(ip, result)
log.debug("geo_lookup_success", ip=ip, country=country_code, asn=asn)
return result
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _str_or_none(value: object) -> str | None:
"""Return *value* as a non-empty string, or ``None``.
Args:
value: Raw JSON value which may be ``None``, empty, or a string.
Returns:
Stripped string if non-empty, else ``None``.
"""
if value is None:
return None
s = str(value).strip()
return s if s else None
def _store(ip: str, info: GeoInfo) -> None:
"""Insert *info* into the module-level cache, flushing if over capacity.
Args:
ip: The IP address key.
info: The :class:`GeoInfo` to store.
"""
if len(_cache) >= _MAX_CACHE_SIZE:
_cache.clear()
log.info("geo_cache_flushed", reason="capacity")
_cache[ip] = info

View File

@@ -1,8 +1,9 @@
"""Tests for the dashboard router (GET /api/dashboard/status)."""
"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans, GET /api/dashboard/accesses)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import aiosqlite
import pytest
@@ -11,6 +12,12 @@ 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.ban import (
AccessListItem,
AccessListResponse,
DashboardBanItem,
DashboardBanListResponse,
)
from app.models.server import ServerStatus
# ---------------------------------------------------------------------------
@@ -56,6 +63,8 @@ async def dashboard_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
total_bans=10,
total_failures=5,
)
# Provide a stub HTTP session so ban/access endpoints can access app.state.http_session.
app.state.http_session = MagicMock()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -94,6 +103,7 @@ async def offline_dashboard_client(tmp_path: Path) -> AsyncClient: # type: igno
app.state.db = db
app.state.server_status = ServerStatus(online=False)
app.state.http_session = MagicMock()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -192,3 +202,190 @@ class TestDashboardStatus:
assert response.status_code == 200
status = response.json()["status"]
assert status["online"] is False
# ---------------------------------------------------------------------------
# Dashboard bans endpoint
# ---------------------------------------------------------------------------
def _make_ban_list_response(n: int = 2) -> DashboardBanListResponse:
"""Build a mock DashboardBanListResponse with *n* items."""
items = [
DashboardBanItem(
ip=f"1.2.3.{i}",
jail="sshd",
banned_at="2026-03-01T10:00:00+00:00",
service=None,
country_code="DE",
country_name="Germany",
asn="AS3320",
org="Telekom",
ban_count=1,
)
for i in range(n)
]
return DashboardBanListResponse(items=items, total=n, page=1, page_size=100)
def _make_access_list_response(n: int = 2) -> AccessListResponse:
"""Build a mock AccessListResponse with *n* items."""
items = [
AccessListItem(
ip=f"5.6.7.{i}",
jail="nginx",
timestamp="2026-03-01T10:00:00+00:00",
line=f"GET /admin HTTP/1.1 attempt {i}",
country_code="US",
country_name="United States",
asn="AS15169",
org="Google LLC",
)
for i in range(n)
]
return AccessListResponse(items=items, total=n, page=1, page_size=100)
class TestDashboardBans:
"""GET /api/dashboard/bans."""
async def test_returns_200_when_authenticated(
self, dashboard_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
with patch(
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response()),
):
response = await dashboard_client.get("/api/dashboard/bans")
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/dashboard/bans")
assert response.status_code == 401
async def test_response_contains_items_and_total(
self, dashboard_client: AsyncClient
) -> None:
"""Response body contains ``items`` list and ``total`` count."""
with patch(
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response(3)),
):
response = await dashboard_client.get("/api/dashboard/bans")
body = response.json()
assert "items" in body
assert "total" in body
assert body["total"] == 3
assert len(body["items"]) == 3
async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None:
"""If no ``range`` param is provided the default ``24h`` preset is used."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans")
called_range = mock_list.call_args[0][1]
assert called_range == "24h"
async def test_accepts_time_range_param(
self, dashboard_client: AsyncClient
) -> None:
"""The ``range`` query parameter is forwarded to ban_service."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans?range=7d")
called_range = mock_list.call_args[0][1]
assert called_range == "7d"
async def test_empty_ban_list_returns_zero_total(
self, dashboard_client: AsyncClient
) -> None:
"""Returns ``total=0`` and empty ``items`` when no bans are in range."""
empty = DashboardBanListResponse(items=[], total=0, page=1, page_size=100)
with patch(
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=empty),
):
response = await dashboard_client.get("/api/dashboard/bans")
body = response.json()
assert body["total"] == 0
assert body["items"] == []
async def test_item_shape_is_correct(self, dashboard_client: AsyncClient) -> None:
"""Each item in ``items`` has the expected fields."""
with patch(
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response(1)),
):
response = await dashboard_client.get("/api/dashboard/bans")
item = response.json()["items"][0]
assert "ip" in item
assert "jail" in item
assert "banned_at" in item
assert "ban_count" in item
# ---------------------------------------------------------------------------
# Dashboard accesses endpoint
# ---------------------------------------------------------------------------
class TestDashboardAccesses:
"""GET /api/dashboard/accesses."""
async def test_returns_200_when_authenticated(
self, dashboard_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
with patch(
"app.routers.dashboard.ban_service.list_accesses",
new=AsyncMock(return_value=_make_access_list_response()),
):
response = await dashboard_client.get("/api/dashboard/accesses")
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/dashboard/accesses")
assert response.status_code == 401
async def test_response_contains_access_items(
self, dashboard_client: AsyncClient
) -> None:
"""Response body contains ``items`` with ``line`` fields."""
with patch(
"app.routers.dashboard.ban_service.list_accesses",
new=AsyncMock(return_value=_make_access_list_response(2)),
):
response = await dashboard_client.get("/api/dashboard/accesses")
body = response.json()
assert body["total"] == 2
assert len(body["items"]) == 2
assert "line" in body["items"][0]
async def test_default_range_is_24h(
self, dashboard_client: AsyncClient
) -> None:
"""If no ``range`` param is provided the default ``24h`` preset is used."""
mock_list = AsyncMock(return_value=_make_access_list_response())
with patch(
"app.routers.dashboard.ban_service.list_accesses", new=mock_list
):
await dashboard_client.get("/api/dashboard/accesses")
called_range = mock_list.call_args[0][1]
assert called_range == "24h"

View File

@@ -0,0 +1,359 @@
"""Tests for ban_service.list_bans() and ban_service.list_accesses()."""
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 ban_service
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_NOW: int = int(time.time())
_ONE_HOUR_AGO: int = _NOW - 3600
_TWO_DAYS_AGO: int = _NOW - 2 * 24 * 3600
async def _create_f2b_db(path: str, rows: list[dict[str, Any]]) -> None:
"""Create a minimal fail2ban SQLite database with the given ban rows.
Args:
path: Filesystem path for the new SQLite file.
rows: Sequence of dicts with keys ``jail``, ``ip``, ``timeofban``,
``bantime``, ``bancount``, and optionally ``data``.
"""
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 with several bans."""
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": 2,
"data": {
"matches": ["Nov 10 10:00 sshd[123]: 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": "9.10.11.12",
"timeofban": _TWO_DAYS_AGO,
"bantime": 3600,
"bancount": 1,
"data": {"failures": 6}, # no matches
},
],
)
return path
@pytest.fixture
async def empty_f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc]
"""Return the path to a fail2ban SQLite database with no ban records."""
path = str(tmp_path / "fail2ban_empty.sqlite3")
await _create_f2b_db(path, [])
return path
# ---------------------------------------------------------------------------
# list_bans — happy path
# ---------------------------------------------------------------------------
class TestListBansHappyPath:
"""Verify ban_service.list_bans() under normal conditions."""
async def test_returns_bans_in_range(self, f2b_db_path: str) -> None:
"""Only bans within the selected range are returned."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans("/fake/sock", "24h")
# Two bans within last 24 h; one is 2 days old and excluded.
assert result.total == 2
assert len(result.items) == 2
async def test_results_sorted_newest_first(self, f2b_db_path: str) -> None:
"""Items are ordered by ``banned_at`` descending (newest first)."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans("/fake/sock", "24h")
timestamps = [item.banned_at for item in result.items]
assert timestamps == sorted(timestamps, reverse=True)
async def test_ban_fields_present(self, f2b_db_path: str) -> None:
"""Each item contains ip, jail, banned_at, ban_count."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans("/fake/sock", "24h")
for item in result.items:
assert item.ip
assert item.jail
assert item.banned_at
assert item.ban_count >= 1
async def test_service_extracted_from_first_match(self, f2b_db_path: str) -> None:
"""``service`` field is the first element of ``data.matches``."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans("/fake/sock", "24h")
sshd_item = next(i for i in result.items if i.jail == "sshd")
assert sshd_item.service is not None
assert "Failed password" in sshd_item.service
async def test_service_is_none_when_no_matches(self, f2b_db_path: str) -> None:
"""``service`` is ``None`` when the ban has no stored matches."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
# Use 7d to include the older ban with no matches.
result = await ban_service.list_bans("/fake/sock", "7d")
no_match = next(i for i in result.items if i.ip == "9.10.11.12")
assert no_match.service is None
async def test_empty_db_returns_zero(self, empty_f2b_db_path: str) -> None:
"""When no bans exist the result has total=0 and no items."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.list_bans("/fake/sock", "24h")
assert result.total == 0
assert result.items == []
async def test_365d_range_includes_old_bans(self, f2b_db_path: str) -> None:
"""The ``365d`` range includes bans that are 2 days old."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans("/fake/sock", "365d")
assert result.total == 3
# ---------------------------------------------------------------------------
# list_bans — geo enrichment
# ---------------------------------------------------------------------------
class TestListBansGeoEnrichment:
"""Verify geo enrichment integration in ban_service.list_bans()."""
async def test_geo_data_applied_when_enricher_provided(
self, f2b_db_path: str
) -> None:
"""Geo fields are populated when an enricher returns data."""
from app.services.geo_service import GeoInfo
async def fake_enricher(ip: str) -> GeoInfo:
return GeoInfo(
country_code="DE",
country_name="Germany",
asn="AS3320",
org="Deutsche Telekom",
)
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans(
"/fake/sock", "24h", geo_enricher=fake_enricher
)
for item in result.items:
assert item.country_code == "DE"
assert item.country_name == "Germany"
assert item.asn == "AS3320"
async def test_geo_failure_does_not_break_results(
self, f2b_db_path: str
) -> None:
"""A geo enricher that raises still returns ban items (geo fields null)."""
async def failing_enricher(ip: str) -> None:
raise RuntimeError("geo service down")
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans(
"/fake/sock", "24h", geo_enricher=failing_enricher
)
assert result.total == 2
for item in result.items:
assert item.country_code is None
# ---------------------------------------------------------------------------
# list_bans — pagination
# ---------------------------------------------------------------------------
class TestListBansPagination:
"""Verify pagination parameters in list_bans()."""
async def test_page_size_respected(self, f2b_db_path: str) -> None:
"""``page_size=1`` returns at most one item."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans("/fake/sock", "7d", page_size=1)
assert len(result.items) == 1
assert result.page_size == 1
async def test_page_2_returns_remaining_items(self, f2b_db_path: str) -> None:
"""The second page returns items not on the first page."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
page1 = await ban_service.list_bans("/fake/sock", "7d", page=1, page_size=1)
page2 = await ban_service.list_bans("/fake/sock", "7d", page=2, page_size=1)
# Different IPs should appear on different pages.
assert page1.items[0].ip != page2.items[0].ip
async def test_total_reflects_full_count_not_page_count(
self, f2b_db_path: str
) -> None:
"""``total`` reports all matching records regardless of pagination."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_bans("/fake/sock", "7d", page_size=1)
assert result.total == 3 # All three bans are within 7d.
# ---------------------------------------------------------------------------
# list_accesses
# ---------------------------------------------------------------------------
class TestListAccesses:
"""Verify ban_service.list_accesses()."""
async def test_expands_matches_into_rows(self, f2b_db_path: str) -> None:
"""Each element in ``data.matches`` becomes a separate row."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
# Two bans in last 24h: sshd (1 match) + nginx (1 match) = 2 rows.
assert result.total == 2
assert len(result.items) == 2
async def test_access_item_has_line_field(self, f2b_db_path: str) -> None:
"""Each access item contains the raw matched log line."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
for item in result.items:
assert item.line
async def test_ban_with_no_matches_produces_no_access_rows(
self, f2b_db_path: str
) -> None:
"""Bans with empty matches list do not contribute rows."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "7d")
# Third ban (9.10.11.12) has no matches, so only 2 rows total.
assert result.total == 2
async def test_empty_db_returns_zero_accesses(
self, empty_f2b_db_path: str
) -> None:
"""Returns empty result when no bans exist."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
assert result.total == 0
assert result.items == []

View File

@@ -0,0 +1,212 @@
"""Tests for geo_service.lookup()."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services import geo_service
from app.services.geo_service import GeoInfo
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_session(response_json: dict[str, object], status: int = 200) -> MagicMock:
"""Build a mock aiohttp.ClientSession that returns *response_json*.
Args:
response_json: The dict that the mock response's ``json()`` returns.
status: HTTP status code for the mock response.
Returns:
A :class:`MagicMock` that behaves like an
``aiohttp.ClientSession`` in an ``async with`` context.
"""
mock_resp = AsyncMock()
mock_resp.status = status
mock_resp.json = AsyncMock(return_value=response_json)
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
session = MagicMock()
session.get = MagicMock(return_value=mock_ctx)
return session
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def clear_geo_cache() -> None: # type: ignore[misc]
"""Flush the module-level geo cache before every test."""
geo_service.clear_cache()
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestLookupSuccess:
"""geo_service.lookup() under normal conditions."""
async def test_returns_country_code(self) -> None:
"""country_code is populated from the ``countryCode`` field."""
session = _make_session(
{
"status": "success",
"countryCode": "DE",
"country": "Germany",
"as": "AS3320 Deutsche Telekom AG",
"org": "AS3320 Deutsche Telekom AG",
}
)
result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
assert result is not None
assert result.country_code == "DE"
async def test_returns_country_name(self) -> None:
"""country_name is populated from the ``country`` field."""
session = _make_session(
{
"status": "success",
"countryCode": "US",
"country": "United States",
"as": "AS15169 Google LLC",
"org": "Google LLC",
}
)
result = await geo_service.lookup("8.8.8.8", session) # type: ignore[arg-type]
assert result is not None
assert result.country_name == "United States"
async def test_asn_extracted_without_org_suffix(self) -> None:
"""The ASN field contains only the ``AS<N>`` prefix, not the full string."""
session = _make_session(
{
"status": "success",
"countryCode": "DE",
"country": "Germany",
"as": "AS3320 Deutsche Telekom AG",
"org": "Deutsche Telekom",
}
)
result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
assert result is not None
assert result.asn == "AS3320"
async def test_org_populated(self) -> None:
"""org field is populated from the ``org`` key."""
session = _make_session(
{
"status": "success",
"countryCode": "US",
"country": "United States",
"as": "AS15169 Google LLC",
"org": "Google LLC",
}
)
result = await geo_service.lookup("8.8.8.8", session) # type: ignore[arg-type]
assert result is not None
assert result.org == "Google LLC"
# ---------------------------------------------------------------------------
# Cache behaviour
# ---------------------------------------------------------------------------
class TestLookupCaching:
"""Verify that results are cached and the cache can be cleared."""
async def test_second_call_uses_cache(self) -> None:
"""Subsequent lookups for the same IP do not make additional HTTP requests."""
session = _make_session(
{
"status": "success",
"countryCode": "DE",
"country": "Germany",
"as": "AS3320 Deutsche Telekom AG",
"org": "Deutsche Telekom",
}
)
await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
# The session.get() should only have been called once.
assert session.get.call_count == 1
async def test_clear_cache_forces_refetch(self) -> None:
"""After clearing the cache a new HTTP request is made."""
session = _make_session(
{
"status": "success",
"countryCode": "DE",
"country": "Germany",
"as": "AS3320",
"org": "Telekom",
}
)
await geo_service.lookup("2.3.4.5", session) # type: ignore[arg-type]
geo_service.clear_cache()
await geo_service.lookup("2.3.4.5", session) # type: ignore[arg-type]
assert session.get.call_count == 2
async def test_negative_result_cached(self) -> None:
"""A failed lookup result (status != success) is also cached."""
session = _make_session(
{"status": "fail", "message": "reserved range"}
)
await geo_service.lookup("192.168.1.1", session) # type: ignore[arg-type]
await geo_service.lookup("192.168.1.1", session) # type: ignore[arg-type]
assert session.get.call_count == 1
# ---------------------------------------------------------------------------
# Failure modes
# ---------------------------------------------------------------------------
class TestLookupFailures:
"""geo_service.lookup() when things go wrong."""
async def test_non_200_response_returns_none(self) -> None:
"""A 429 or 500 status returns ``None`` without caching."""
session = _make_session({}, status=429)
result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
assert result is None
async def test_network_error_returns_none(self) -> None:
"""A network exception returns ``None``."""
session = MagicMock()
session.get = MagicMock(side_effect=OSError("connection refused"))
result = await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type]
assert result is None
async def test_failed_status_returns_geo_info_with_nulls(self) -> None:
"""When ip-api returns ``status=fail`` a GeoInfo with null fields is cached."""
session = _make_session({"status": "fail", "message": "private range"})
result = await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type]
assert result is not None
assert isinstance(result, GeoInfo)
assert result.country_code is None
assert result.country_name is None

View File

@@ -25,8 +25,10 @@
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jiti": "^2.6.1",
"prettier": "^3.3.3",
"typescript": "^5.6.3",
"typescript-eslint": "^8.56.1",
"vite": "^5.4.11"
}
},
@@ -4204,6 +4206,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4884,6 +4896,30 @@
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
"integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/utils": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",

View File

@@ -30,8 +30,10 @@
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jiti": "^2.6.1",
"prettier": "^3.3.3",
"typescript": "^5.6.3",
"typescript-eslint": "^8.56.1",
"vite": "^5.4.11"
}
}

View File

@@ -1,20 +1,68 @@
/**
* Dashboard API module.
*
* Wraps the `GET /api/dashboard/status` endpoint.
* Wraps `GET /api/dashboard/status`, `GET /api/dashboard/bans`, and
* `GET /api/dashboard/accesses`.
*/
import { get } from "./client";
import { ENDPOINTS } from "./endpoints";
import type { AccessListResponse, DashboardBanListResponse, TimeRange } from "../types/ban";
import type { ServerStatusResponse } from "../types/server";
/**
* Fetch the cached fail2ban server status from the backend.
*
* @returns The server status response containing ``online``, ``version``,
* ``active_jails``, ``total_bans``, and ``total_failures``.
* @returns The server status response containing `online`, `version`,
* `active_jails`, `total_bans`, and `total_failures`.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchServerStatus(): Promise<ServerStatusResponse> {
return get<ServerStatusResponse>(ENDPOINTS.dashboardStatus);
}
/**
* Fetch a paginated ban list for the selected time window.
*
* @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
* @param page - 1-based page number (default `1`).
* @param pageSize - Items per page (default `100`).
* @returns Paginated {@link DashboardBanListResponse}.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchBans(
range: TimeRange,
page = 1,
pageSize = 100,
): Promise<DashboardBanListResponse> {
const params = new URLSearchParams({
range,
page: String(page),
page_size: String(pageSize),
});
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
}
/**
* Fetch a paginated access list (individual matched log lines) for the
* selected time window.
*
* @param range - Time-range preset.
* @param page - 1-based page number (default `1`).
* @param pageSize - Items per page (default `100`).
* @returns Paginated {@link AccessListResponse}.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchAccesses(
range: TimeRange,
page = 1,
pageSize = 100,
): Promise<AccessListResponse> {
const params = new URLSearchParams({
range,
page: String(page),
page_size: String(pageSize),
});
return get<AccessListResponse>(`/api/dashboard/accesses?${params.toString()}`);
}

View File

@@ -0,0 +1,394 @@
/**
* `BanTable` component.
*
* Renders a Fluent UI v9 `DataGrid` for the dashboard ban-list and
* access-list views. Uses the {@link useBans} hook to fetch and manage
* paginated data from the backend.
*
* Columns differ between modes:
* - `"bans"` — Time, IP, Service, Country, Jail, Ban Count.
* - `"accesses"` — Time, IP, Log Line, Country, Jail.
*/
import {
Badge,
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
MessageBar,
MessageBarBody,
Spinner,
Text,
Tooltip,
makeStyles,
tokens,
type TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { useBans, type BanTableMode } from "../hooks/useBans";
import type { AccessListItem, DashboardBanItem, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props for the {@link BanTable} component. */
interface BanTableProps {
/** Whether to render ban records or individual access events. */
mode: BanTableMode;
/**
* Active time-range preset — controlled by the parent `DashboardPage`.
* Changing this value triggers a re-fetch.
*/
timeRange: TimeRange;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
minHeight: "300px",
},
centred: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: tokens.spacingVerticalXXL,
},
tableWrapper: {
overflowX: "auto",
},
pagination: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: tokens.spacingHorizontalS,
paddingTop: tokens.spacingVerticalS,
},
mono: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
},
truncate: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "280px",
display: "inline-block",
},
countBadge: {
fontVariantNumeric: "tabular-nums",
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Format an ISO 8601 timestamp for display.
*
* @param iso - ISO 8601 UTC string.
* @returns Localised date+time string.
*/
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/** Columns for the ban-list view (`mode === "bans"`). */
function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefinition<DashboardBanItem>[] {
return [
createTableColumn<DashboardBanItem>({
columnId: "banned_at",
renderHeaderCell: () => "Time of Ban",
renderCell: (item) => (
<Text size={200}>{formatTimestamp(item.banned_at)}</Text>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: (item) => (
<span className={styles.mono}>{item.ip}</span>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "service",
renderHeaderCell: () => "Service / URL",
renderCell: (item) =>
item.service ? (
<Tooltip content={item.service} relationship="description">
<span className={`${styles.mono} ${styles.truncate}`}>{item.service}</span>
</Tooltip>
) : (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
</Text>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (item) => (
<Text size={200}>
{item.country_name ?? item.country_code ?? "—"}
</Text>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
}),
createTableColumn<DashboardBanItem>({
columnId: "ban_count",
renderHeaderCell: () => "Bans",
renderCell: (item) => (
<Badge
appearance={item.ban_count > 1 ? "filled" : "outline"}
color={item.ban_count > 5 ? "danger" : item.ban_count > 1 ? "warning" : "informative"}
className={styles.countBadge}
>
{item.ban_count}
</Badge>
),
}),
];
}
/** Columns for the access-list view (`mode === "accesses"`). */
function buildAccessColumns(styles: ReturnType<typeof useStyles>): TableColumnDefinition<AccessListItem>[] {
return [
createTableColumn<AccessListItem>({
columnId: "timestamp",
renderHeaderCell: () => "Timestamp",
renderCell: (item) => (
<Text size={200}>{formatTimestamp(item.timestamp)}</Text>
),
}),
createTableColumn<AccessListItem>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: (item) => (
<span className={styles.mono}>{item.ip}</span>
),
}),
createTableColumn<AccessListItem>({
columnId: "line",
renderHeaderCell: () => "Log Line",
renderCell: (item) => (
<Tooltip content={item.line} relationship="description">
<span className={`${styles.mono} ${styles.truncate}`}>{item.line}</span>
</Tooltip>
),
}),
createTableColumn<AccessListItem>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (item) => (
<Text size={200}>
{item.country_name ?? item.country_code ?? "—"}
</Text>
),
}),
createTableColumn<AccessListItem>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
}),
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Data table for the dashboard ban-list and access-list views.
*
* @param props.mode - `"bans"` or `"accesses"`.
* @param props.timeRange - Active time-range preset from the parent page.
*/
export function BanTable({ mode, timeRange }: BanTableProps): React.JSX.Element {
const styles = useStyles();
const { banItems, accessItems, total, page, setPage, loading, error } = useBans(
mode,
timeRange,
);
const banColumns = buildBanColumns(styles);
const accessColumns = buildAccessColumns(styles);
// --------------------------------------------------------------------------
// Loading state
// --------------------------------------------------------------------------
if (loading) {
return (
<div className={styles.centred}>
<Spinner label="Loading…" />
</div>
);
}
// --------------------------------------------------------------------------
// Error state
// --------------------------------------------------------------------------
if (error) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
// --------------------------------------------------------------------------
// Empty state
// --------------------------------------------------------------------------
const isEmpty = mode === "bans" ? banItems.length === 0 : accessItems.length === 0;
if (isEmpty) {
return (
<div className={styles.centred}>
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
No {mode === "bans" ? "bans" : "accesses"} recorded in the selected time window.
</Text>
</div>
);
}
// --------------------------------------------------------------------------
// Pagination helpers
// --------------------------------------------------------------------------
const pageSize = 100;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 1;
const hasNext = page < totalPages;
// --------------------------------------------------------------------------
// Render — bans mode
// --------------------------------------------------------------------------
if (mode === "bans") {
return (
<div className={styles.root}>
<div className={styles.tableWrapper}>
<DataGrid
items={banItems}
columns={banColumns}
getRowId={(item: DashboardBanItem) => `${item.ip}:${item.jail}:${item.banned_at}`}
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<DashboardBanItem>>
{({ item, rowId }) => (
<DataGridRow<DashboardBanItem> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
<div className={styles.pagination}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
{total} total · Page {page} of {totalPages}
</Text>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
disabled={!hasPrev}
onClick={() => { setPage(page - 1); }}
aria-label="Previous page"
/>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
disabled={!hasNext}
onClick={() => { setPage(page + 1); }}
aria-label="Next page"
/>
</div>
</div>
);
}
// --------------------------------------------------------------------------
// Render — accesses mode
// --------------------------------------------------------------------------
return (
<div className={styles.root}>
<div className={styles.tableWrapper}>
<DataGrid
items={accessItems}
columns={accessColumns}
getRowId={(item: AccessListItem) => `${item.ip}:${item.jail}:${item.timestamp}:${item.line.slice(0, 40)}`}
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<AccessListItem>>
{({ item, rowId }) => (
<DataGridRow<AccessListItem> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
<div className={styles.pagination}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
{total} total · Page {page} of {totalPages}
</Text>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
disabled={!hasPrev}
onClick={() => { setPage(page - 1); }}
aria-label="Previous page"
/>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
disabled={!hasNext}
onClick={() => { setPage(page + 1); }}
aria-label="Next page"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
/**
* `useBans` hook.
*
* Fetches and manages paginated ban-list or access-list data from the
* dashboard endpoints. Re-fetches automatically when `timeRange` or `page`
* changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchAccesses, fetchBans } from "../api/dashboard";
import type { AccessListItem, DashboardBanItem, TimeRange } from "../types/ban";
/** The dashboard view mode: aggregate bans or individual access events. */
export type BanTableMode = "bans" | "accesses";
/** Items per page for the ban/access tables. */
const PAGE_SIZE = 100;
/** Return value shape for {@link useBans}. */
export interface UseBansResult {
/** Ban items — populated when `mode === "bans"`, otherwise empty. */
banItems: DashboardBanItem[];
/** Access items — populated when `mode === "accesses"`, otherwise empty. */
accessItems: AccessListItem[];
/** Total records in the selected time window (for pagination). */
total: number;
/** Current 1-based page number. */
page: number;
/** Navigate to a specific page. */
setPage: (p: number) => void;
/** Whether a fetch is currently in flight. */
loading: boolean;
/** Error message if the last fetch failed, otherwise `null`. */
error: string | null;
/** Imperatively re-fetch the current page. */
refresh: () => void;
}
/**
* Fetch and manage dashboard ban-list or access-list data.
*
* Automatically re-fetches when `mode`, `timeRange`, or `page` changes.
*
* @param mode - `"bans"` for the ban-list view; `"accesses"` for the
* access-list view.
* @param timeRange - Time-range preset that controls how far back to look.
* @returns Current data, pagination state, loading flag, and a `refresh`
* callback.
*/
export function useBans(mode: BanTableMode, timeRange: TimeRange): UseBansResult {
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
const [accessItems, setAccessItems] = useState<AccessListItem[]>([]);
const [total, setTotal] = useState<number>(0);
const [page, setPage] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Reset page when mode or time range changes.
useEffect(() => {
setPage(1);
}, [mode, timeRange]);
const doFetch = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
if (mode === "bans") {
const data = await fetchBans(timeRange, page, PAGE_SIZE);
setBanItems(data.items);
setAccessItems([]);
setTotal(data.total);
} else {
const data = await fetchAccesses(timeRange, page, PAGE_SIZE);
setAccessItems(data.items);
setBanItems([]);
setTotal(data.total);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch data");
} finally {
setLoading(false);
}
}, [mode, timeRange, page]);
// Stable ref to the latest doFetch so the refresh callback is always current.
const doFetchRef = useRef(doFetch);
doFetchRef.current = doFetch;
useEffect(() => {
void doFetch();
}, [doFetch]);
const refresh = useCallback((): void => {
void doFetchRef.current();
}, []);
return {
banItems,
accessItems,
total,
page,
setPage,
loading,
error,
refresh,
};
}

View File

@@ -1,12 +1,31 @@
/**
* Dashboard page.
*
* Shows the fail2ban server status bar at the top.
* Full ban-list implementation is delivered in Stage 5.
* Composes the fail2ban server status bar at the top, a shared time-range
* selector, and two tabs: "Ban List" (aggregate bans) and "Access List"
* (individual matched log lines). The time-range selection is shared
* between both tabs so users can compare data for the same period.
*/
import { Text, makeStyles, tokens } from "@fluentui/react-components";
import { useState } from "react";
import {
Tab,
TabList,
Text,
ToggleButton,
Toolbar,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { BanTable } from "../components/BanTable";
import { ServerStatusBar } from "../components/ServerStatusBar";
import type { TimeRange } from "../types/ban";
import { TIME_RANGE_LABELS } from "../types/ban";
import type { BanTableMode } from "../hooks/useBans";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
@@ -14,22 +33,116 @@ const useStyles = makeStyles({
flexDirection: "column",
gap: tokens.spacingVerticalM,
},
section: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
sectionHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: tokens.spacingHorizontalM,
paddingBottom: tokens.spacingVerticalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
},
tabContent: {
paddingTop: tokens.spacingVerticalS,
},
});
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Ordered time-range presets for the toolbar. */
const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Dashboard page — renders the server status bar and a Stage 5 placeholder.
* Main dashboard landing page.
*
* Displays the fail2ban server status, a time-range selector, and a
* tabbed view toggling between the ban list and the access list.
*/
export function DashboardPage(): JSX.Element {
export function DashboardPage(): React.JSX.Element {
const styles = useStyles();
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [activeTab, setActiveTab] = useState<BanTableMode>("bans");
return (
<div className={styles.root}>
{/* ------------------------------------------------------------------ */}
{/* Server status bar */}
{/* ------------------------------------------------------------------ */}
<ServerStatusBar />
<Text as="h1" size={700} weight="semibold">
Dashboard
</Text>
<Text as="p" size={300}>
Ban overview will be implemented in Stage 5.
</Text>
{/* ------------------------------------------------------------------ */}
{/* Ban / access list section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
{activeTab === "bans" ? "Ban List" : "Access List"}
</Text>
{/* Shared time-range selector */}
<Toolbar aria-label="Time range" size="small">
{TIME_RANGES.map((r) => (
<ToggleButton
key={r}
size="small"
checked={timeRange === r}
onClick={() => {
setTimeRange(r);
}}
aria-pressed={timeRange === r}
>
{TIME_RANGE_LABELS[r]}
</ToggleButton>
))}
</Toolbar>
</div>
{/* Tab switcher */}
<TabList
selectedValue={activeTab}
onTabSelect={(_, data) => {
setActiveTab(data.value as BanTableMode);
}}
size="small"
>
<Tab value="bans">Ban List</Tab>
<Tab value="accesses">Access List</Tab>
</TabList>
{/* Active tab content */}
<div className={styles.tabContent}>
<BanTable mode={activeTab} timeRange={timeRange} />
</div>
</div>
</div>
);
}

113
frontend/src/types/ban.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* TypeScript interfaces mirroring the backend ban Pydantic models.
*
* `backend/app/models/ban.py` — dashboard dashboard sections.
*/
// ---------------------------------------------------------------------------
// Time-range selector
// ---------------------------------------------------------------------------
/** The four supported time-range presets for dashboard views. */
export type TimeRange = "24h" | "7d" | "30d" | "365d";
/** Human-readable labels for each time-range preset. */
export const TIME_RANGE_LABELS: Record<TimeRange, string> = {
"24h": "Last 24 h",
"7d": "Last 7 days",
"30d": "Last 30 days",
"365d": "Last 365 days",
} as const;
// ---------------------------------------------------------------------------
// Ban-list table item
// ---------------------------------------------------------------------------
/**
* A single row in the dashboard ban-list table.
*
* Mirrors `DashboardBanItem` from `backend/app/models/ban.py`.
*/
export interface DashboardBanItem {
/** Banned IP address. */
ip: string;
/** Jail that issued the ban. */
jail: string;
/** ISO 8601 UTC timestamp of the ban. */
banned_at: string;
/** First matched log line (context for the ban), or null. */
service: string | null;
/** ISO 3166-1 alpha-2 country code, or null if unknown. */
country_code: string | null;
/** Human-readable country name, or null if unknown. */
country_name: string | null;
/** Autonomous System Number string, e.g. "AS3320", or null. */
asn: string | null;
/** Organisation name associated with the IP, or null. */
org: string | null;
/** How many times this IP was banned. */
ban_count: number;
}
/**
* Paginated ban-list response from `GET /api/dashboard/bans`.
*
* Mirrors `DashboardBanListResponse` from `backend/app/models/ban.py`.
*/
export interface DashboardBanListResponse {
/** Ban items for the current page. */
items: DashboardBanItem[];
/** Total number of bans in the selected time window. */
total: number;
/** Current 1-based page number. */
page: number;
/** Maximum items per page. */
page_size: number;
}
// ---------------------------------------------------------------------------
// Access-list table item
// ---------------------------------------------------------------------------
/**
* A single row in the dashboard access-list table.
*
* Each row represents one matched log line (failure attempt) that
* contributed to a ban.
*
* Mirrors `AccessListItem` from `backend/app/models/ban.py`.
*/
export interface AccessListItem {
/** IP address of the access event. */
ip: string;
/** Jail that recorded the access. */
jail: string;
/** ISO 8601 UTC timestamp of the ban that captured this access. */
timestamp: string;
/** Raw matched log line. */
line: string;
/** ISO 3166-1 alpha-2 country code, or null. */
country_code: string | null;
/** Human-readable country name, or null. */
country_name: string | null;
/** ASN string, or null. */
asn: string | null;
/** Organisation name, or null. */
org: string | null;
}
/**
* Paginated access-list response from `GET /api/dashboard/accesses`.
*
* Mirrors `AccessListResponse` from `backend/app/models/ban.py`.
*/
export interface AccessListResponse {
/** Access items for the current page. */
items: AccessListItem[];
/** Total number of access events in the selected window. */
total: number;
/** Current 1-based page number. */
page: number;
/** Maximum items per page. */
page_size: number;
}