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:
@@ -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.",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user