feat: implement dashboard ban overview (Stage 5)
- Add ban_service reading fail2ban SQLite DB via read-only aiosqlite - Add geo_service resolving IPs via ip-api.com with 10k in-memory cache - Add GET /api/dashboard/bans and GET /api/dashboard/accesses endpoints - Add TimeRange, DashboardBanItem, DashboardBanListResponse, AccessListItem, AccessListResponse models in models/ban.py - Build BanTable component (Fluent UI DataGrid) with bans/accesses modes, pagination, loading/error/empty states, and ban-count badges - Build useBans hook managing time-range and pagination state - Update DashboardPage: status bar + time-range toolbar + tab switcher - Add 37 new backend tests (ban service, geo service, dashboard router) - All 141 tests pass; ruff/mypy --strict/tsc --noEmit clean
This commit is contained in:
@@ -3,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)
|
||||
|
||||
Reference in New Issue
Block a user