feat: Task 4 — paginated banned-IPs section on jail detail page
Backend:
- Add JailBannedIpsResponse Pydantic model (ban.py)
- Add get_jail_banned_ips() service: server-side pagination, optional
IP substring search, geo enrichment on page slice only (jail_service.py)
- Add GET /api/jails/{name}/banned endpoint with page/page_size/search
query params, 400/404/502 error handling (routers/jails.py)
- 23 new tests: 13 service tests + 10 router tests (all passing)
Frontend:
- Add JailBannedIpsResponse TS interface (types/jail.ts)
- Add jailBanned endpoint helper (api/endpoints.ts)
- Add fetchJailBannedIps() API function (api/jails.ts)
- Add BannedIpsSection component: Fluent UI DataGrid, debounced search
(300 ms), prev/next pagination, page-size dropdown, per-row unban
button, loading spinner, empty state, error MessageBar (BannedIpsSection.tsx)
- Mount BannedIpsSection in JailDetailPage between stats and patterns
- 12 new Vitest tests for BannedIpsSection (all passing)
This commit is contained in:
@@ -266,7 +266,7 @@ Build a multi-layered safety net that:
|
|||||||
|
|
||||||
## Task 4 — Jail Detail Page: Paginated "Currently Banned IPs" List
|
## Task 4 — Jail Detail Page: Paginated "Currently Banned IPs" List
|
||||||
|
|
||||||
**Status:** not started
|
**Status:** done
|
||||||
**References:** [Features.md § 5 — Jail Management](Features.md), [Architekture.md § 2](Architekture.md)
|
**References:** [Features.md § 5 — Jail Management](Features.md), [Architekture.md § 2](Architekture.md)
|
||||||
|
|
||||||
### Problem
|
### Problem
|
||||||
|
|||||||
@@ -306,3 +306,30 @@ class BansByJailResponse(BaseModel):
|
|||||||
description="Jails ordered by ban count descending.",
|
description="Jails ordered by ban count descending.",
|
||||||
)
|
)
|
||||||
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
|
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Jail-specific paginated bans
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class JailBannedIpsResponse(BaseModel):
|
||||||
|
"""Paginated response for ``GET /api/jails/{name}/banned``.
|
||||||
|
|
||||||
|
Contains only the current page of active ban entries for a single jail,
|
||||||
|
geo-enriched exclusively for the page slice to avoid rate-limit issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
|
items: list[ActiveBan] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Active ban entries for the current page.",
|
||||||
|
)
|
||||||
|
total: int = Field(
|
||||||
|
...,
|
||||||
|
ge=0,
|
||||||
|
description="Total matching entries (after applying the search filter).",
|
||||||
|
)
|
||||||
|
page: int = Field(..., ge=1, description="Current page number (1-based).")
|
||||||
|
page_size: int = Field(..., ge=1, description="Number of items per page.")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Provides CRUD and control operations for fail2ban jails:
|
|||||||
|
|
||||||
* ``GET /api/jails`` — list all jails
|
* ``GET /api/jails`` — list all jails
|
||||||
* ``GET /api/jails/{name}`` — full detail for one jail
|
* ``GET /api/jails/{name}`` — full detail for one jail
|
||||||
|
* ``GET /api/jails/{name}/banned`` — paginated currently-banned IPs for one jail
|
||||||
* ``POST /api/jails/{name}/start`` — start a jail
|
* ``POST /api/jails/{name}/start`` — start a jail
|
||||||
* ``POST /api/jails/{name}/stop`` — stop a jail
|
* ``POST /api/jails/{name}/stop`` — stop a jail
|
||||||
* ``POST /api/jails/{name}/idle`` — toggle idle mode
|
* ``POST /api/jails/{name}/idle`` — toggle idle mode
|
||||||
@@ -23,6 +24,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Body, HTTPException, Path, Request, status
|
from fastapi import APIRouter, Body, HTTPException, Path, Request, status
|
||||||
|
|
||||||
from app.dependencies import AuthDep
|
from app.dependencies import AuthDep
|
||||||
|
from app.models.ban import JailBannedIpsResponse
|
||||||
from app.models.jail import (
|
from app.models.jail import (
|
||||||
IgnoreIpRequest,
|
IgnoreIpRequest,
|
||||||
JailCommandResponse,
|
JailCommandResponse,
|
||||||
@@ -540,3 +542,74 @@ async def toggle_ignore_self(
|
|||||||
raise _conflict(str(exc)) from exc
|
raise _conflict(str(exc)) from exc
|
||||||
except Fail2BanConnectionError as exc:
|
except Fail2BanConnectionError as exc:
|
||||||
raise _bad_gateway(exc) from exc
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Currently banned IPs (paginated)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{name}/banned",
|
||||||
|
response_model=JailBannedIpsResponse,
|
||||||
|
summary="Return paginated currently-banned IPs for a single jail",
|
||||||
|
)
|
||||||
|
async def get_jail_banned_ips(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
name: _NamePath,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> JailBannedIpsResponse:
|
||||||
|
"""Return a paginated list of IPs currently banned by a specific jail.
|
||||||
|
|
||||||
|
The full ban list is fetched from the fail2ban socket, filtered by the
|
||||||
|
optional *search* substring, sliced to the requested page, and then
|
||||||
|
geo-enriched exclusively for that page slice.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request (used to access ``app.state``).
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
name: Jail name.
|
||||||
|
page: 1-based page number (default 1, min 1).
|
||||||
|
page_size: Items per page (default 25, max 100).
|
||||||
|
search: Optional case-insensitive substring filter on the IP address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 when *page* or *page_size* are out of range.
|
||||||
|
HTTPException: 404 when the jail does not exist.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
if page < 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="page must be >= 1.",
|
||||||
|
)
|
||||||
|
if not (1 <= page_size <= 100):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="page_size must be between 1 and 100.",
|
||||||
|
)
|
||||||
|
|
||||||
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||||
|
http_session = getattr(request.app.state, "http_session", None)
|
||||||
|
app_db = getattr(request.app.state, "db", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await jail_service.get_jail_banned_ips(
|
||||||
|
socket_path=socket_path,
|
||||||
|
jail_name=name,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
search=search,
|
||||||
|
http_session=http_session,
|
||||||
|
app_db=app_db,
|
||||||
|
)
|
||||||
|
except JailNotFoundError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from typing import Any
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from app.models.ban import ActiveBan, ActiveBanListResponse
|
from app.models.ban import ActiveBan, ActiveBanListResponse, JailBannedIpsResponse
|
||||||
from app.models.config import BantimeEscalation
|
from app.models.config import BantimeEscalation
|
||||||
from app.models.jail import (
|
from app.models.jail import (
|
||||||
Jail,
|
Jail,
|
||||||
@@ -862,6 +862,120 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — Jail-specific paginated bans
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Maximum allowed page size for :func:`get_jail_banned_ips`.
|
||||||
|
_MAX_PAGE_SIZE: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jail_banned_ips(
|
||||||
|
socket_path: str,
|
||||||
|
jail_name: str,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
search: str | None = None,
|
||||||
|
http_session: Any | None = None,
|
||||||
|
app_db: Any | None = None,
|
||||||
|
) -> JailBannedIpsResponse:
|
||||||
|
"""Return a paginated list of currently banned IPs for a single jail.
|
||||||
|
|
||||||
|
Fetches the full ban list from the fail2ban socket, applies an optional
|
||||||
|
substring search filter on the IP, paginates server-side, and geo-enriches
|
||||||
|
**only** the current page slice to stay within rate limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
jail_name: Name of the jail to query.
|
||||||
|
page: 1-based page number (default 1).
|
||||||
|
page_size: Items per page; clamped to :data:`_MAX_PAGE_SIZE` (default 25).
|
||||||
|
search: Optional case-insensitive substring filter applied to IP addresses.
|
||||||
|
http_session: Optional shared :class:`aiohttp.ClientSession` for geo
|
||||||
|
enrichment via :func:`~app.services.geo_service.lookup_batch`.
|
||||||
|
app_db: Optional BanGUI application database for persistent geo cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JailNotFoundError: If *jail_name* is not a known active jail.
|
||||||
|
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket is
|
||||||
|
unreachable.
|
||||||
|
"""
|
||||||
|
from app.services import geo_service # noqa: PLC0415
|
||||||
|
|
||||||
|
# Clamp page_size to the allowed maximum.
|
||||||
|
page_size = min(page_size, _MAX_PAGE_SIZE)
|
||||||
|
|
||||||
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||||
|
|
||||||
|
# Verify the jail exists.
|
||||||
|
try:
|
||||||
|
_ok(await client.send(["status", jail_name, "short"]))
|
||||||
|
except ValueError as exc:
|
||||||
|
if _is_not_found_error(exc):
|
||||||
|
raise JailNotFoundError(jail_name) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Fetch the full ban list for this jail.
|
||||||
|
try:
|
||||||
|
raw_result = _ok(await client.send(["get", jail_name, "banip", "--with-time"]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raw_result = []
|
||||||
|
|
||||||
|
ban_list: list[str] = raw_result or []
|
||||||
|
|
||||||
|
# Parse all entries.
|
||||||
|
all_bans: list[ActiveBan] = []
|
||||||
|
for entry in ban_list:
|
||||||
|
ban = _parse_ban_entry(str(entry), jail_name)
|
||||||
|
if ban is not None:
|
||||||
|
all_bans.append(ban)
|
||||||
|
|
||||||
|
# Apply optional substring search filter (case-insensitive).
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
all_bans = [b for b in all_bans if search_lower in b.ip.lower()]
|
||||||
|
|
||||||
|
total = len(all_bans)
|
||||||
|
|
||||||
|
# Slice the requested page.
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
page_bans = all_bans[start : start + page_size]
|
||||||
|
|
||||||
|
# Geo-enrich only the page slice.
|
||||||
|
if http_session is not None and page_bans:
|
||||||
|
page_ips = [b.ip for b in page_bans]
|
||||||
|
try:
|
||||||
|
geo_map = await geo_service.lookup_batch(page_ips, http_session, db=app_db)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.warning("jail_banned_ips_geo_failed", jail=jail_name)
|
||||||
|
geo_map = {}
|
||||||
|
enriched_page: list[ActiveBan] = []
|
||||||
|
for ban in page_bans:
|
||||||
|
geo = geo_map.get(ban.ip)
|
||||||
|
if geo is not None:
|
||||||
|
enriched_page.append(ban.model_copy(update={"country": geo.country_code}))
|
||||||
|
else:
|
||||||
|
enriched_page.append(ban)
|
||||||
|
page_bans = enriched_page
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"jail_banned_ips_fetched",
|
||||||
|
jail=jail_name,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return JailBannedIpsResponse(
|
||||||
|
items=page_bans,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _enrich_bans(
|
async def _enrich_bans(
|
||||||
bans: list[ActiveBan],
|
bans: list[ActiveBan],
|
||||||
geo_enricher: Any,
|
geo_enricher: Any,
|
||||||
|
|||||||
@@ -788,3 +788,146 @@ class TestFail2BanConnectionErrors:
|
|||||||
resp = await jails_client.post("/api/jails/sshd/reload")
|
resp = await jails_client.post("/api/jails/sshd/reload")
|
||||||
|
|
||||||
assert resp.status_code == 502
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/jails/{name}/banned
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetJailBannedIps:
|
||||||
|
"""Tests for ``GET /api/jails/{name}/banned``."""
|
||||||
|
|
||||||
|
def _mock_response(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
items: list[dict] | None = None,
|
||||||
|
total: int = 2,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
) -> "JailBannedIpsResponse": # type: ignore[name-defined]
|
||||||
|
from app.models.ban import ActiveBan, JailBannedIpsResponse
|
||||||
|
|
||||||
|
ban_items = (
|
||||||
|
[
|
||||||
|
ActiveBan(
|
||||||
|
ip=item.get("ip", "1.2.3.4"),
|
||||||
|
jail="sshd",
|
||||||
|
banned_at=item.get("banned_at", "2025-01-01T10:00:00+00:00"),
|
||||||
|
expires_at=item.get("expires_at", "2025-01-01T10:10:00+00:00"),
|
||||||
|
ban_count=1,
|
||||||
|
country=item.get("country", None),
|
||||||
|
)
|
||||||
|
for item in (items or [{"ip": "1.2.3.4"}, {"ip": "5.6.7.8"}])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return JailBannedIpsResponse(
|
||||||
|
items=ban_items, total=total, page=page, page_size=page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_200_returns_paginated_bans(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/sshd/banned returns 200 with a JailBannedIpsResponse."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.jails.jail_service.get_jail_banned_ips",
|
||||||
|
AsyncMock(return_value=self._mock_response()),
|
||||||
|
):
|
||||||
|
resp = await jails_client.get("/api/jails/sshd/banned")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert data["total"] == 2
|
||||||
|
|
||||||
|
async def test_200_with_search_parameter(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/sshd/banned?search=1.2.3 passes search to service."""
|
||||||
|
mock_fn = AsyncMock(return_value=self._mock_response(items=[{"ip": "1.2.3.4"}], total=1))
|
||||||
|
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
|
||||||
|
resp = await jails_client.get("/api/jails/sshd/banned?search=1.2.3")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
_args, call_kwargs = mock_fn.call_args
|
||||||
|
assert call_kwargs.get("search") == "1.2.3"
|
||||||
|
|
||||||
|
async def test_200_with_page_and_page_size(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/sshd/banned?page=2&page_size=10 passes params to service."""
|
||||||
|
mock_fn = AsyncMock(
|
||||||
|
return_value=self._mock_response(page=2, page_size=10, total=0, items=[])
|
||||||
|
)
|
||||||
|
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
|
||||||
|
resp = await jails_client.get("/api/jails/sshd/banned?page=2&page_size=10")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
_args, call_kwargs = mock_fn.call_args
|
||||||
|
assert call_kwargs.get("page") == 2
|
||||||
|
assert call_kwargs.get("page_size") == 10
|
||||||
|
|
||||||
|
async def test_400_when_page_is_zero(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/sshd/banned?page=0 returns 400."""
|
||||||
|
resp = await jails_client.get("/api/jails/sshd/banned?page=0")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
async def test_400_when_page_size_exceeds_max(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/sshd/banned?page_size=200 returns 400."""
|
||||||
|
resp = await jails_client.get("/api/jails/sshd/banned?page_size=200")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
async def test_400_when_page_size_is_zero(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/sshd/banned?page_size=0 returns 400."""
|
||||||
|
resp = await jails_client.get("/api/jails/sshd/banned?page_size=0")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/ghost/banned returns 404 when jail is unknown."""
|
||||||
|
from app.services.jail_service import JailNotFoundError
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.routers.jails.jail_service.get_jail_banned_ips",
|
||||||
|
AsyncMock(side_effect=JailNotFoundError("ghost")),
|
||||||
|
):
|
||||||
|
resp = await jails_client.get("/api/jails/ghost/banned")
|
||||||
|
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_502_when_fail2ban_unreachable(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/sshd/banned returns 502 when fail2ban is unreachable."""
|
||||||
|
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.routers.jails.jail_service.get_jail_banned_ips",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
resp = await jails_client.get("/api/jails/sshd/banned")
|
||||||
|
|
||||||
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
async def test_response_items_have_expected_fields(
|
||||||
|
self, jails_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Response items contain ip, jail, banned_at, expires_at, ban_count, country."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.jails.jail_service.get_jail_banned_ips",
|
||||||
|
AsyncMock(return_value=self._mock_response()),
|
||||||
|
):
|
||||||
|
resp = await jails_client.get("/api/jails/sshd/banned")
|
||||||
|
|
||||||
|
item = resp.json()["items"][0]
|
||||||
|
assert "ip" in item
|
||||||
|
assert "jail" in item
|
||||||
|
assert "banned_at" in item
|
||||||
|
assert "expires_at" in item
|
||||||
|
assert "ban_count" in item
|
||||||
|
assert "country" in item
|
||||||
|
|
||||||
|
async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None:
|
||||||
|
"""GET /api/jails/sshd/banned returns 401 without a session cookie."""
|
||||||
|
resp = await AsyncClient(
|
||||||
|
transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined]
|
||||||
|
base_url="http://test",
|
||||||
|
).get("/api/jails/sshd/banned")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.models.ban import ActiveBanListResponse
|
from app.models.ban import ActiveBanListResponse, JailBannedIpsResponse
|
||||||
from app.models.jail import JailDetailResponse, JailListResponse
|
from app.models.jail import JailDetailResponse, JailListResponse
|
||||||
from app.services import jail_service
|
from app.services import jail_service
|
||||||
from app.services.jail_service import JailNotFoundError, JailOperationError
|
from app.services.jail_service import JailNotFoundError, JailOperationError
|
||||||
@@ -700,3 +700,201 @@ class TestUnbanAllIps:
|
|||||||
pytest.raises(Fail2BanConnectionError),
|
pytest.raises(Fail2BanConnectionError),
|
||||||
):
|
):
|
||||||
await jail_service.unban_all_ips(_SOCKET)
|
await jail_service.unban_all_ips(_SOCKET)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_jail_banned_ips
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: A raw ban entry string in the format produced by fail2ban --with-time.
|
||||||
|
_BAN_ENTRY_1 = "1.2.3.4\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"
|
||||||
|
_BAN_ENTRY_2 = "5.6.7.8\t2025-01-01 11:00:00 + 600 = 2025-01-01 11:10:00"
|
||||||
|
_BAN_ENTRY_3 = "9.10.11.12\t2025-01-01 12:00:00 + 600 = 2025-01-01 12:10:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _banned_ips_responses(jail: str = "sshd", entries: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
"""Build mock responses for get_jail_banned_ips tests."""
|
||||||
|
if entries is None:
|
||||||
|
entries = [_BAN_ENTRY_1, _BAN_ENTRY_2]
|
||||||
|
return {
|
||||||
|
f"status|{jail}|short": _make_short_status(),
|
||||||
|
f"get|{jail}|banip|--with-time": (0, entries),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetJailBannedIps:
|
||||||
|
"""Unit tests for :func:`~app.services.jail_service.get_jail_banned_ips`."""
|
||||||
|
|
||||||
|
async def test_returns_jail_banned_ips_response(self) -> None:
|
||||||
|
"""get_jail_banned_ips returns a JailBannedIpsResponse."""
|
||||||
|
with _patch_client(_banned_ips_responses()):
|
||||||
|
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
|
||||||
|
|
||||||
|
assert isinstance(result, JailBannedIpsResponse)
|
||||||
|
|
||||||
|
async def test_total_reflects_all_entries(self) -> None:
|
||||||
|
"""total equals the number of parsed ban entries."""
|
||||||
|
with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])):
|
||||||
|
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
|
||||||
|
|
||||||
|
assert result.total == 3
|
||||||
|
|
||||||
|
async def test_page_1_returns_first_n_items(self) -> None:
|
||||||
|
"""page=1 with page_size=2 returns the first two entries."""
|
||||||
|
with _patch_client(
|
||||||
|
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
|
||||||
|
):
|
||||||
|
result = await jail_service.get_jail_banned_ips(
|
||||||
|
_SOCKET, "sshd", page=1, page_size=2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.items) == 2
|
||||||
|
assert result.items[0].ip == "1.2.3.4"
|
||||||
|
assert result.items[1].ip == "5.6.7.8"
|
||||||
|
assert result.total == 3
|
||||||
|
|
||||||
|
async def test_page_2_returns_remaining_items(self) -> None:
|
||||||
|
"""page=2 with page_size=2 returns the third entry."""
|
||||||
|
with _patch_client(
|
||||||
|
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
|
||||||
|
):
|
||||||
|
result = await jail_service.get_jail_banned_ips(
|
||||||
|
_SOCKET, "sshd", page=2, page_size=2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.items) == 1
|
||||||
|
assert result.items[0].ip == "9.10.11.12"
|
||||||
|
|
||||||
|
async def test_page_beyond_last_returns_empty_items(self) -> None:
|
||||||
|
"""Requesting a page past the end returns an empty items list."""
|
||||||
|
with _patch_client(_banned_ips_responses()):
|
||||||
|
result = await jail_service.get_jail_banned_ips(
|
||||||
|
_SOCKET, "sshd", page=99, page_size=25
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.items == []
|
||||||
|
assert result.total == 2
|
||||||
|
|
||||||
|
async def test_search_filter_narrows_results(self) -> None:
|
||||||
|
"""search parameter filters entries by IP substring."""
|
||||||
|
with _patch_client(_banned_ips_responses()):
|
||||||
|
result = await jail_service.get_jail_banned_ips(
|
||||||
|
_SOCKET, "sshd", search="1.2.3"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 1
|
||||||
|
assert result.items[0].ip == "1.2.3.4"
|
||||||
|
|
||||||
|
async def test_search_filter_case_insensitive(self) -> None:
|
||||||
|
"""search filter is case-insensitive."""
|
||||||
|
entries = ["192.168.0.1\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"]
|
||||||
|
with _patch_client(_banned_ips_responses(entries=entries)):
|
||||||
|
result = await jail_service.get_jail_banned_ips(
|
||||||
|
_SOCKET, "sshd", search="192.168"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 1
|
||||||
|
|
||||||
|
async def test_search_no_match_returns_empty(self) -> None:
|
||||||
|
"""search that matches nothing returns empty items and total=0."""
|
||||||
|
with _patch_client(_banned_ips_responses()):
|
||||||
|
result = await jail_service.get_jail_banned_ips(
|
||||||
|
_SOCKET, "sshd", search="999.999"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 0
|
||||||
|
assert result.items == []
|
||||||
|
|
||||||
|
async def test_empty_ban_list_returns_total_zero(self) -> None:
|
||||||
|
"""get_jail_banned_ips handles an empty ban list gracefully."""
|
||||||
|
responses = {
|
||||||
|
"status|sshd|short": _make_short_status(),
|
||||||
|
"get|sshd|banip|--with-time": (0, []),
|
||||||
|
}
|
||||||
|
with _patch_client(responses):
|
||||||
|
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
|
||||||
|
|
||||||
|
assert result.total == 0
|
||||||
|
assert result.items == []
|
||||||
|
|
||||||
|
async def test_page_size_clamped_to_max(self) -> None:
|
||||||
|
"""page_size values above 100 are silently clamped to 100."""
|
||||||
|
entries = [f"10.0.0.{i}\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00" for i in range(1, 101)]
|
||||||
|
responses = {
|
||||||
|
"status|sshd|short": _make_short_status(),
|
||||||
|
"get|sshd|banip|--with-time": (0, entries),
|
||||||
|
}
|
||||||
|
with _patch_client(responses):
|
||||||
|
result = await jail_service.get_jail_banned_ips(
|
||||||
|
_SOCKET, "sshd", page=1, page_size=200
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.items) <= 100
|
||||||
|
|
||||||
|
async def test_geo_enrichment_called_for_page_slice_only(self) -> None:
|
||||||
|
"""Geo enrichment is requested only for IPs in the current page."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.services import geo_service
|
||||||
|
|
||||||
|
http_session = MagicMock()
|
||||||
|
geo_enrichment_ips: list[list[str]] = []
|
||||||
|
|
||||||
|
async def _mock_lookup_batch(
|
||||||
|
ips: list[str], _session: Any, **_kw: Any
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
geo_enrichment_ips.append(list(ips))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
_patch_client(
|
||||||
|
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
|
||||||
|
),
|
||||||
|
patch.object(geo_service, "lookup_batch", side_effect=_mock_lookup_batch),
|
||||||
|
):
|
||||||
|
result = await jail_service.get_jail_banned_ips(
|
||||||
|
_SOCKET,
|
||||||
|
"sshd",
|
||||||
|
page=1,
|
||||||
|
page_size=2,
|
||||||
|
http_session=http_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only the 2-IP page slice should be passed to geo enrichment.
|
||||||
|
assert len(geo_enrichment_ips) == 1
|
||||||
|
assert len(geo_enrichment_ips[0]) == 2
|
||||||
|
assert result.total == 3
|
||||||
|
|
||||||
|
async def test_unknown_jail_raises_jail_not_found_error(self) -> None:
|
||||||
|
"""get_jail_banned_ips raises JailNotFoundError for unknown jail."""
|
||||||
|
responses = {
|
||||||
|
"status|ghost|short": (0, pytest.raises), # will be overridden
|
||||||
|
}
|
||||||
|
# Simulate fail2ban returning an "unknown jail" error.
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, **_kw: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send(self, command: list[Any]) -> Any:
|
||||||
|
raise ValueError("Unknown jail: ghost")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.jail_service.Fail2BanClient", _FakeClient),
|
||||||
|
pytest.raises(JailNotFoundError),
|
||||||
|
):
|
||||||
|
await jail_service.get_jail_banned_ips(_SOCKET, "ghost")
|
||||||
|
|
||||||
|
async def test_connection_error_propagates(self) -> None:
|
||||||
|
"""get_jail_banned_ips propagates Fail2BanConnectionError."""
|
||||||
|
|
||||||
|
class _FailClient:
|
||||||
|
def __init__(self, **_kw: Any) -> None:
|
||||||
|
self.send = AsyncMock(
|
||||||
|
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.jail_service.Fail2BanClient", _FailClient),
|
||||||
|
pytest.raises(Fail2BanConnectionError),
|
||||||
|
):
|
||||||
|
await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const ENDPOINTS = {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
jails: "/jails",
|
jails: "/jails",
|
||||||
jail: (name: string): string => `/jails/${encodeURIComponent(name)}`,
|
jail: (name: string): string => `/jails/${encodeURIComponent(name)}`,
|
||||||
|
jailBanned: (name: string): string => `/jails/${encodeURIComponent(name)}/banned`,
|
||||||
jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`,
|
jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`,
|
||||||
jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`,
|
jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`,
|
||||||
jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`,
|
jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ENDPOINTS } from "./endpoints";
|
|||||||
import type {
|
import type {
|
||||||
ActiveBanListResponse,
|
ActiveBanListResponse,
|
||||||
IpLookupResponse,
|
IpLookupResponse,
|
||||||
|
JailBannedIpsResponse,
|
||||||
JailCommandResponse,
|
JailCommandResponse,
|
||||||
JailDetailResponse,
|
JailDetailResponse,
|
||||||
JailListResponse,
|
JailListResponse,
|
||||||
@@ -224,3 +225,37 @@ export async function unbanAllBans(): Promise<UnbanAllResponse> {
|
|||||||
export async function lookupIp(ip: string): Promise<IpLookupResponse> {
|
export async function lookupIp(ip: string): Promise<IpLookupResponse> {
|
||||||
return get<IpLookupResponse>(ENDPOINTS.geoLookup(ip));
|
return get<IpLookupResponse>(ENDPOINTS.geoLookup(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Jail-specific paginated bans
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the currently banned IPs for a specific jail, paginated.
|
||||||
|
*
|
||||||
|
* Only the requested page is geo-enriched on the backend, so this call
|
||||||
|
* remains fast even when a jail has thousands of banned IPs.
|
||||||
|
*
|
||||||
|
* @param jailName - Jail name (e.g. `"sshd"`).
|
||||||
|
* @param page - 1-based page number (default 1).
|
||||||
|
* @param pageSize - Items per page; max 100 (default 25).
|
||||||
|
* @param search - Optional case-insensitive IP substring filter.
|
||||||
|
* @returns A {@link JailBannedIpsResponse} with paginated ban entries.
|
||||||
|
* @throws {ApiError} On non-2xx responses (404 if jail unknown, 502 if fail2ban down).
|
||||||
|
*/
|
||||||
|
export async function fetchJailBannedIps(
|
||||||
|
jailName: string,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 25,
|
||||||
|
search?: string,
|
||||||
|
): Promise<JailBannedIpsResponse> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
};
|
||||||
|
if (search !== undefined && search !== "") {
|
||||||
|
params.search = search;
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams(params).toString();
|
||||||
|
return get<JailBannedIpsResponse>(`${ENDPOINTS.jailBanned(jailName)}?${query}`);
|
||||||
|
}
|
||||||
|
|||||||
466
frontend/src/components/jail/BannedIpsSection.tsx
Normal file
466
frontend/src/components/jail/BannedIpsSection.tsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* `BannedIpsSection` component.
|
||||||
|
*
|
||||||
|
* Displays a paginated table of IPs currently banned in a specific fail2ban
|
||||||
|
* jail. Supports server-side search filtering (debounced), page navigation,
|
||||||
|
* page-size selection, and per-row unban actions.
|
||||||
|
*
|
||||||
|
* Only the current page is geo-enriched by the backend, so the component
|
||||||
|
* remains fast even when a jail contains thousands of banned IPs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
DataGrid,
|
||||||
|
DataGridBody,
|
||||||
|
DataGridCell,
|
||||||
|
DataGridHeader,
|
||||||
|
DataGridHeaderCell,
|
||||||
|
DataGridRow,
|
||||||
|
Dropdown,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
Option,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
|
type TableColumnDefinition,
|
||||||
|
createTableColumn,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import {
|
||||||
|
ArrowClockwiseRegular,
|
||||||
|
ChevronLeftRegular,
|
||||||
|
ChevronRightRegular,
|
||||||
|
DismissRegular,
|
||||||
|
SearchRegular,
|
||||||
|
} from "@fluentui/react-icons";
|
||||||
|
import { fetchJailBannedIps, unbanIp } from "../../api/jails";
|
||||||
|
import type { ActiveBan } from "../../types/jail";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Debounce delay in milliseconds for the search input. */
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/** Available page-size options. */
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Styles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root: {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: tokens.spacingHorizontalM,
|
||||||
|
paddingBottom: tokens.spacingVerticalS,
|
||||||
|
borderBottomWidth: "1px",
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: tokens.colorNeutralStroke2,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: tokens.spacingHorizontalM,
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
searchField: {
|
||||||
|
minWidth: "200px",
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
tableWrapper: {
|
||||||
|
overflowX: "auto",
|
||||||
|
},
|
||||||
|
centred: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: tokens.spacingVerticalXXL,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
paddingTop: tokens.spacingVerticalS,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
pageSizeWrapper: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: tokens.spacingHorizontalXS,
|
||||||
|
},
|
||||||
|
mono: {
|
||||||
|
fontFamily: "Consolas, 'Courier New', monospace",
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO 8601 timestamp for compact display.
|
||||||
|
*
|
||||||
|
* @param iso - ISO 8601 string or `null`.
|
||||||
|
* @returns A locale time string, or `"—"` when `null`.
|
||||||
|
*/
|
||||||
|
function fmtTime(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Column definitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A row item augmented with an `onUnban` callback for the row action. */
|
||||||
|
interface BanRow {
|
||||||
|
ban: ActiveBan;
|
||||||
|
onUnban: (ip: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnDefinition<BanRow>[] = [
|
||||||
|
createTableColumn<BanRow>({
|
||||||
|
columnId: "ip",
|
||||||
|
renderHeaderCell: () => "IP Address",
|
||||||
|
renderCell: ({ ban }) => (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: "Consolas, 'Courier New', monospace",
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ban.ip}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
createTableColumn<BanRow>({
|
||||||
|
columnId: "country",
|
||||||
|
renderHeaderCell: () => "Country",
|
||||||
|
renderCell: ({ ban }) =>
|
||||||
|
ban.country ? (
|
||||||
|
<Text size={200}>{ban.country}</Text>
|
||||||
|
) : (
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground4 }}>
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
createTableColumn<BanRow>({
|
||||||
|
columnId: "banned_at",
|
||||||
|
renderHeaderCell: () => "Banned At",
|
||||||
|
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.banned_at)}</Text>,
|
||||||
|
}),
|
||||||
|
createTableColumn<BanRow>({
|
||||||
|
columnId: "expires_at",
|
||||||
|
renderHeaderCell: () => "Expires At",
|
||||||
|
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.expires_at)}</Text>,
|
||||||
|
}),
|
||||||
|
createTableColumn<BanRow>({
|
||||||
|
columnId: "actions",
|
||||||
|
renderHeaderCell: () => "",
|
||||||
|
renderCell: ({ ban, onUnban }) => (
|
||||||
|
<Tooltip content={`Unban ${ban.ip}`} relationship="label">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={() => {
|
||||||
|
onUnban(ban.ip);
|
||||||
|
}}
|
||||||
|
aria-label={`Unban ${ban.ip}`}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Props
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Props for {@link BannedIpsSection}. */
|
||||||
|
export interface BannedIpsSectionProps {
|
||||||
|
/** The jail name whose banned IPs are displayed. */
|
||||||
|
jailName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated section showing currently banned IPs for a single jail.
|
||||||
|
*
|
||||||
|
* @param props - {@link BannedIpsSectionProps}
|
||||||
|
*/
|
||||||
|
export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX.Element {
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ActiveBan[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState<number>(25);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [opError, setOpError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Debounce the search input so we don't spam the backend on every keystroke.
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current !== null) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout((): void => {
|
||||||
|
setDebouncedSearch(search);
|
||||||
|
setPage(1);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
return (): void => {
|
||||||
|
if (debounceRef.current !== null) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined)
|
||||||
|
.then((resp) => {
|
||||||
|
setItems(resp.items);
|
||||||
|
setTotal(resp.total);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err instanceof ApiError
|
||||||
|
? `${String(err.status)}: ${err.body}`
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: String(err);
|
||||||
|
setError(msg);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [jailName, page, pageSize, debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleUnban = (ip: string): void => {
|
||||||
|
setOpError(null);
|
||||||
|
unbanIp(ip, jailName)
|
||||||
|
.then(() => {
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err instanceof ApiError
|
||||||
|
? `${String(err.status)}: ${err.body}`
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: String(err);
|
||||||
|
setOpError(msg);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: BanRow[] = items.map((ban) => ({
|
||||||
|
ban,
|
||||||
|
onUnban: handleUnban,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
{/* Section header */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.headerLeft}>
|
||||||
|
<Text as="h2" size={500} weight="semibold">
|
||||||
|
Currently Banned IPs
|
||||||
|
</Text>
|
||||||
|
<Badge appearance="tint">{String(total)}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowClockwiseRegular />}
|
||||||
|
onClick={load}
|
||||||
|
aria-label="Refresh banned IPs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div className={styles.searchField}>
|
||||||
|
<Field label="Search by IP">
|
||||||
|
<Input
|
||||||
|
aria-label="Search by IP"
|
||||||
|
contentBefore={<SearchRegular />}
|
||||||
|
placeholder="e.g. 192.168"
|
||||||
|
value={search}
|
||||||
|
onChange={(_, d) => {
|
||||||
|
setSearch(d.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error bars */}
|
||||||
|
{error && (
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{error}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
{opError && (
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{opError}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.centred}>
|
||||||
|
<Spinner label="Loading banned IPs…" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={styles.centred}>
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
No IPs currently banned in this jail.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<DataGrid
|
||||||
|
items={rows}
|
||||||
|
columns={columns}
|
||||||
|
getRowId={(row: BanRow) => row.ban.ip}
|
||||||
|
focusMode="composite"
|
||||||
|
>
|
||||||
|
<DataGridHeader>
|
||||||
|
<DataGridRow>
|
||||||
|
{({ renderHeaderCell }) => (
|
||||||
|
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||||
|
)}
|
||||||
|
</DataGridRow>
|
||||||
|
</DataGridHeader>
|
||||||
|
<DataGridBody<BanRow>>
|
||||||
|
{({ item, rowId }) => (
|
||||||
|
<DataGridRow<BanRow> key={rowId}>
|
||||||
|
{({ renderCell }) => (
|
||||||
|
<DataGridCell>{renderCell(item)}</DataGridCell>
|
||||||
|
)}
|
||||||
|
</DataGridRow>
|
||||||
|
)}
|
||||||
|
</DataGridBody>
|
||||||
|
</DataGrid>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<div className={styles.pageSizeWrapper}>
|
||||||
|
<Text size={200}>Rows per page:</Text>
|
||||||
|
<Dropdown
|
||||||
|
aria-label="Rows per page"
|
||||||
|
value={String(pageSize)}
|
||||||
|
selectedOptions={[String(pageSize)]}
|
||||||
|
onOptionSelect={(_, d) => {
|
||||||
|
const newSize = Number(d.optionValue);
|
||||||
|
if (!Number.isNaN(newSize)) {
|
||||||
|
setPageSize(newSize);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ minWidth: "80px" }}
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((n) => (
|
||||||
|
<Option key={n} value={String(n)}>
|
||||||
|
{String(n)}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text size={200}>
|
||||||
|
{String((page - 1) * pageSize + 1)}–
|
||||||
|
{String(Math.min(page * pageSize, total))} of {String(total)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ChevronLeftRegular />}
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => {
|
||||||
|
setPage((p) => Math.max(1, p - 1));
|
||||||
|
}}
|
||||||
|
aria-label="Previous page"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ChevronRightRegular />}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => {
|
||||||
|
setPage((p) => p + 1);
|
||||||
|
}}
|
||||||
|
aria-label="Next page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx
Normal file
251
frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the `BannedIpsSection` component.
|
||||||
|
*
|
||||||
|
* Verifies:
|
||||||
|
* - Renders the section header and total count badge.
|
||||||
|
* - Shows a spinner while loading.
|
||||||
|
* - Renders a table with IP rows on success.
|
||||||
|
* - Shows an empty-state message when there are no banned IPs.
|
||||||
|
* - Displays an error message bar when the API call fails.
|
||||||
|
* - Search input re-fetches with the search parameter after debounce.
|
||||||
|
* - Unban button calls `unbanIp` and refreshes the list.
|
||||||
|
* - Pagination buttons are shown and change the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
import { BannedIpsSection } from "../BannedIpsSection";
|
||||||
|
import type { JailBannedIpsResponse } from "../../../types/jail";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const { mockFetchJailBannedIps, mockUnbanIp } = vi.hoisted(() => ({
|
||||||
|
mockFetchJailBannedIps: vi.fn<
|
||||||
|
(
|
||||||
|
jailName: string,
|
||||||
|
page?: number,
|
||||||
|
pageSize?: number,
|
||||||
|
search?: string,
|
||||||
|
) => Promise<JailBannedIpsResponse>
|
||||||
|
>(),
|
||||||
|
mockUnbanIp: vi.fn<
|
||||||
|
(ip: string, jail?: string) => Promise<{ message: string; jail: string }>
|
||||||
|
>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../api/jails", () => ({
|
||||||
|
fetchJailBannedIps: mockFetchJailBannedIps,
|
||||||
|
unbanIp: mockUnbanIp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeBan(ip: string) {
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
jail: "sshd",
|
||||||
|
banned_at: "2025-01-01T10:00:00+00:00",
|
||||||
|
expires_at: "2025-01-01T10:10:00+00:00",
|
||||||
|
ban_count: 1,
|
||||||
|
country: "US",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResponse(
|
||||||
|
ips: string[] = ["1.2.3.4", "5.6.7.8"],
|
||||||
|
total = 2,
|
||||||
|
): JailBannedIpsResponse {
|
||||||
|
return {
|
||||||
|
items: ips.map(makeBan),
|
||||||
|
total,
|
||||||
|
page: 1,
|
||||||
|
page_size: 25,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_RESPONSE: JailBannedIpsResponse = {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
page_size: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderSection(jailName = "sshd") {
|
||||||
|
return render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<BannedIpsSection jailName={jailName} />
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("BannedIpsSection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders section header with 'Currently Banned IPs' title", async () => {
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Currently Banned IPs")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the total count badge", async () => {
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"], 2));
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a spinner while loading", () => {
|
||||||
|
// Never resolves during this test so we see the spinner.
|
||||||
|
mockFetchJailBannedIps.mockReturnValue(new Promise(() => void 0));
|
||||||
|
renderSection();
|
||||||
|
expect(screen.getByText("Loading banned IPs…")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders IP rows when banned IPs exist", async () => {
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"]));
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("1.2.3.4")).toBeTruthy();
|
||||||
|
expect(screen.getByText("5.6.7.8")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty-state message when no IPs are banned", async () => {
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(EMPTY_RESPONSE);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("No IPs currently banned in this jail."),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error message bar on API failure", async () => {
|
||||||
|
mockFetchJailBannedIps.mockRejectedValue(new Error("socket dead"));
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/socket dead/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls fetchJailBannedIps with the jail name", async () => {
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
|
||||||
|
renderSection("nginx");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchJailBannedIps).toHaveBeenCalledWith(
|
||||||
|
"nginx",
|
||||||
|
expect.any(Number),
|
||||||
|
expect.any(Number),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search input re-fetches after debounce with the search term", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
|
||||||
|
renderSection();
|
||||||
|
// Flush pending async work from the initial render (no timer advancement needed).
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
mockFetchJailBannedIps.mockClear();
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(
|
||||||
|
makeResponse(["1.2.3.4"], 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
// fireEvent is synchronous — avoids hanging with fake timers.
|
||||||
|
const input = screen.getByPlaceholderText("e.g. 192.168");
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(input, { target: { value: "1.2.3" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance just past the 300ms debounce delay and flush promises.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchJailBannedIps).toHaveBeenLastCalledWith(
|
||||||
|
"sshd",
|
||||||
|
expect.any(Number),
|
||||||
|
expect.any(Number),
|
||||||
|
"1.2.3",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls unbanIp when the unban button is clicked", async () => {
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4"]));
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("1.2.3.4")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
|
||||||
|
await userEvent.click(unbanBtn);
|
||||||
|
|
||||||
|
expect(mockUnbanIp).toHaveBeenCalledWith("1.2.3.4", "sshd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes list after successful unban", async () => {
|
||||||
|
mockFetchJailBannedIps
|
||||||
|
.mockResolvedValueOnce(makeResponse(["1.2.3.4"]))
|
||||||
|
.mockResolvedValue(EMPTY_RESPONSE);
|
||||||
|
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
|
||||||
|
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("1.2.3.4")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
|
||||||
|
await userEvent.click(unbanBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchJailBannedIps).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows pagination controls when total > 0", async () => {
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(
|
||||||
|
makeResponse(["1.2.3.4", "5.6.7.8"], 50),
|
||||||
|
);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Next page")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("Previous page")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previous page button is disabled on page 1", async () => {
|
||||||
|
mockFetchJailBannedIps.mockResolvedValue(
|
||||||
|
makeResponse(["1.2.3.4"], 50),
|
||||||
|
);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => {
|
||||||
|
const prevBtn = screen.getByLabelText("Previous page");
|
||||||
|
expect(prevBtn).toHaveAttribute("disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import { useJailDetail } from "../hooks/useJails";
|
import { useJailDetail } from "../hooks/useJails";
|
||||||
import type { Jail } from "../types/jail";
|
import type { Jail } from "../types/jail";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
@@ -624,6 +625,7 @@ export function JailDetailPage(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<JailInfoSection jail={jail} onRefresh={refresh} />
|
<JailInfoSection jail={jail} onRefresh={refresh} />
|
||||||
|
<BannedIpsSection jailName={name} />
|
||||||
<PatternsSection jail={jail} />
|
<PatternsSection jail={jail} />
|
||||||
<BantimeEscalationSection jail={jail} />
|
<BantimeEscalationSection jail={jail} />
|
||||||
<IgnoreListSection
|
<IgnoreListSection
|
||||||
|
|||||||
@@ -198,6 +198,26 @@ export interface UnbanAllResponse {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Jail-specific paginated bans
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated response from `GET /api/jails/{name}/banned`.
|
||||||
|
*
|
||||||
|
* Mirrors `JailBannedIpsResponse` from `backend/app/models/ban.py`.
|
||||||
|
*/
|
||||||
|
export interface JailBannedIpsResponse {
|
||||||
|
/** Active ban entries for the current page. */
|
||||||
|
items: ActiveBan[];
|
||||||
|
/** Total matching entries (after applying any search filter). */
|
||||||
|
total: number;
|
||||||
|
/** Current page number (1-based). */
|
||||||
|
page: number;
|
||||||
|
/** Number of items per page. */
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GeoDetail {
|
export interface GeoDetail {
|
||||||
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
|
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
|
||||||
country_code: string | null;
|
country_code: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user