feature/ignore-self-toggle #1
@@ -123,6 +123,7 @@ backend/
|
|||||||
│ │ └── import_log_repo.py # Import run history records
|
│ │ └── import_log_repo.py # Import run history records
|
||||||
│ ├── tasks/ # APScheduler background jobs
|
│ ├── tasks/ # APScheduler background jobs
|
||||||
│ │ ├── blocklist_import.py# Scheduled blocklist download and application
|
│ │ ├── blocklist_import.py# Scheduled blocklist download and application
|
||||||
|
│ │ ├── geo_cache_flush.py # Periodic geo cache persistence (dirty-set flush to SQLite)
|
||||||
│ │ └── health_check.py # Periodic fail2ban connectivity probe
|
│ │ └── health_check.py # Periodic fail2ban connectivity probe
|
||||||
│ └── utils/ # Helpers, constants, shared types
|
│ └── utils/ # Helpers, constants, shared types
|
||||||
│ ├── fail2ban_client.py # Async wrapper around the fail2ban socket protocol
|
│ ├── fail2ban_client.py # Async wrapper around the fail2ban socket protocol
|
||||||
@@ -200,6 +201,7 @@ APScheduler background jobs that run on a schedule without user interaction.
|
|||||||
| Task | Purpose |
|
| Task | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `blocklist_import.py` | Downloads all enabled blocklist sources, validates entries, applies bans, records results in the import log |
|
| `blocklist_import.py` | Downloads all enabled blocklist sources, validates entries, applies bans, records results in the import log |
|
||||||
|
| `geo_cache_flush.py` | Periodically flushes newly resolved IPs from the in-memory dirty set to the `geo_cache` SQLite table (default: every 60 seconds). GET requests populate only the in-memory cache; this task persists them without blocking any request. |
|
||||||
| `health_check.py` | Periodically pings the fail2ban socket and updates the cached server status so the frontend always has fresh data |
|
| `health_check.py` | Periodically pings the fail2ban socket and updates the cached server status so the frontend always has fresh data |
|
||||||
|
|
||||||
#### Utils (`app/utils/`)
|
#### Utils (`app/utils/`)
|
||||||
@@ -586,6 +588,7 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `settings` | Key-value store for application configuration (master password hash, fail2ban socket path, database path, timezone, session duration) |
|
| `settings` | Key-value store for application configuration (master password hash, fail2ban socket path, database path, timezone, session duration) |
|
||||||
| `sessions` | Active session tokens with expiry timestamps |
|
| `sessions` | Active session tokens with expiry timestamps |
|
||||||
|
| `geo_cache` | Resolved IP geolocation results (ip, country_code, country_name, asn, org, cached_at). Loaded into memory at startup via `load_cache_from_db()`; new entries are flushed back by the `geo_cache_flush` background task. |
|
||||||
| `blocklist_sources` | Registered blocklist URLs (id, name, url, enabled, created_at, updated_at) |
|
| `blocklist_sources` | Registered blocklist URLs (id, name, url, enabled, created_at, updated_at) |
|
||||||
| `import_logs` | Record of every blocklist import run (id, source_id, timestamp, ips_imported, ips_skipped, errors, status) |
|
| `import_logs` | Record of every blocklist import run (id, source_id, timestamp, ips_imported, ips_skipped, errors, status) |
|
||||||
|
|
||||||
@@ -606,6 +609,8 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas
|
|||||||
- Session expiry is configurable (set during setup, stored in `settings`).
|
- Session expiry is configurable (set during setup, stored in `settings`).
|
||||||
- The frontend `AuthProvider` checks session validity on mount and redirects to `/login` if invalid.
|
- The frontend `AuthProvider` checks session validity on mount and redirects to `/login` if invalid.
|
||||||
- The backend `dependencies.py` provides an `authenticated` dependency that validates the session cookie on every protected endpoint.
|
- The backend `dependencies.py` provides an `authenticated` dependency that validates the session cookie on every protected endpoint.
|
||||||
|
- **Session validation cache** — validated session tokens are cached in memory for 10 seconds (`_session_cache` dict in `dependencies.py`) to avoid a SQLite round-trip on every request from the same browser. The cache is invalidated immediately on logout.
|
||||||
|
- **Setup-completion flag** — once `is_setup_complete()` returns `True`, the result is stored in `app.state._setup_complete_cached`. The `SetupRedirectMiddleware` skips the DB query on all subsequent requests, removing 1 SQL query per request for the common post-setup case.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -619,6 +624,7 @@ APScheduler 4.x (async mode) manages recurring background tasks.
|
|||||||
│ (async, in-process) │
|
│ (async, in-process) │
|
||||||
├──────────────────────┤
|
├──────────────────────┤
|
||||||
│ blocklist_import │ ── runs on configured schedule (default: daily 03:00)
|
│ blocklist_import │ ── runs on configured schedule (default: daily 03:00)
|
||||||
|
│ geo_cache_flush │ ── runs every 60 seconds
|
||||||
│ health_check │ ── runs every 30 seconds
|
│ health_check │ ── runs every 30 seconds
|
||||||
└──────────────────────┘
|
└──────────────────────┘
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -353,7 +353,15 @@ Add the `BanTrendChart` to the dashboard page **above** the two country charts a
|
|||||||
|
|
||||||
### Task 5.1 — Add a backend endpoint for ban counts per jail
|
### Task 5.1 — Add a backend endpoint for ban counts per jail
|
||||||
|
|
||||||
**Status:** `not started`
|
**Status:** `done`
|
||||||
|
|
||||||
|
Added `GET /api/dashboard/bans/by-jail`. New Pydantic models `JailBanCount` and
|
||||||
|
`BansByJailResponse` added to `ban.py`. Service function `bans_by_jail()` in
|
||||||
|
`ban_service.py` queries the `bans` table with `GROUP BY jail ORDER BY COUNT(*) DESC`
|
||||||
|
and applies the origin filter. Route added to `dashboard.py`. 7 new service tests
|
||||||
|
(happy path, total equality, empty DB, time-window exclusion, origin filter variants)
|
||||||
|
and 10 new router tests — all pass, total suite 497 passed, 83% coverage.
|
||||||
|
`ruff check` and `mypy --strict` pass.
|
||||||
|
|
||||||
The existing `GET /api/jails` endpoint returns jail metadata with `status.currently_banned` — but this counts **currently active** bans, not historical bans in the selected time window. The dashboard needs historical ban counts per jail within the selected time range.
|
The existing `GET /api/jails` endpoint returns jail metadata with `status.currently_banned` — but this counts **currently active** bans, not historical bans in the selected time window. The dashboard needs historical ban counts per jail within the selected time range.
|
||||||
|
|
||||||
@@ -395,7 +403,16 @@ class BansByJailResponse(BaseModel):
|
|||||||
|
|
||||||
### Task 5.2 — Create the `JailDistributionChart` component
|
### Task 5.2 — Create the `JailDistributionChart` component
|
||||||
|
|
||||||
**Status:** `not started`
|
**Status:** `done`
|
||||||
|
|
||||||
|
Created `frontend/src/components/JailDistributionChart.tsx` — a horizontal
|
||||||
|
bar chart using Recharts `BarChart` showing ban counts per jail sorted descending.
|
||||||
|
Added `JailBanCount`/`BansByJailResponse` types to `types/ban.ts`,
|
||||||
|
`dashboardBansByJail` constant to `api/endpoints.ts`, `fetchBansByJail()` to
|
||||||
|
`api/dashboard.ts`, and the `useJailDistribution` hook at
|
||||||
|
`hooks/useJailDistribution.ts`. Component handles loading (Spinner), error
|
||||||
|
(MessageBar), and empty states inline. `tsc --noEmit` and ESLint pass with zero
|
||||||
|
warnings.
|
||||||
|
|
||||||
Create `frontend/src/components/JailDistributionChart.tsx`. This component renders a **horizontal bar chart** showing the distribution of bans across jails.
|
Create `frontend/src/components/JailDistributionChart.tsx`. This component renders a **horizontal bar chart** showing the distribution of bans across jails.
|
||||||
|
|
||||||
@@ -422,7 +439,14 @@ Create `frontend/src/components/JailDistributionChart.tsx`. This component rende
|
|||||||
|
|
||||||
### Task 5.3 — Integrate the jail distribution chart into `DashboardPage`
|
### Task 5.3 — Integrate the jail distribution chart into `DashboardPage`
|
||||||
|
|
||||||
**Status:** `not started`
|
**Status:** `done`
|
||||||
|
|
||||||
|
Added a full-width "Jail Distribution" section card to `DashboardPage` below the
|
||||||
|
"Top Countries" section (2-column country charts on row 1, jail chart full-width
|
||||||
|
on row 2). The section renders `<JailDistributionChart timeRange={timeRange}
|
||||||
|
origin={originFilter} />`, sharing the same state already used by the other
|
||||||
|
charts. Loading, error, and empty states are handled inside
|
||||||
|
`JailDistributionChart` itself. `tsc --noEmit` and ESLint pass with zero warnings.
|
||||||
|
|
||||||
Add the `JailDistributionChart` as a third chart card alongside the two country charts, or in a second chart row below them if space is constrained.
|
Add the `JailDistributionChart` as a third chart card alongside the two country charts, or in a second chart row below them if space is constrained.
|
||||||
|
|
||||||
|
|||||||
@@ -280,3 +280,29 @@ class BanTrendResponse(BaseModel):
|
|||||||
...,
|
...,
|
||||||
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
|
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# By-jail endpoint models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class JailBanCount(BaseModel):
|
||||||
|
"""A single jail entry in the bans-by-jail aggregation."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
|
jail: str = Field(..., description="Jail name.")
|
||||||
|
count: int = Field(..., ge=0, description="Number of bans recorded in this jail.")
|
||||||
|
|
||||||
|
|
||||||
|
class BansByJailResponse(BaseModel):
|
||||||
|
"""Response for the ``GET /api/dashboard/bans/by-jail`` endpoint."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
|
jails: list[JailBanCount] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Jails ordered by ban count descending.",
|
||||||
|
)
|
||||||
|
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ fail2ban server health snapshot. The snapshot is maintained by the
|
|||||||
background health-check task and refreshed every 30 seconds.
|
background health-check task and refreshed every 30 seconds.
|
||||||
|
|
||||||
Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
|
Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
|
||||||
``GET /api/dashboard/bans/by-country`` for country aggregation, and
|
``GET /api/dashboard/bans/by-country`` for country aggregation,
|
||||||
``GET /api/dashboard/bans/trend`` for time-bucketed ban counts.
|
``GET /api/dashboard/bans/trend`` for time-bucketed ban counts, and
|
||||||
|
``GET /api/dashboard/bans/by-jail`` for per-jail ban counts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,6 +23,7 @@ from app.dependencies import AuthDep
|
|||||||
from app.models.ban import (
|
from app.models.ban import (
|
||||||
BanOrigin,
|
BanOrigin,
|
||||||
BansByCountryResponse,
|
BansByCountryResponse,
|
||||||
|
BansByJailResponse,
|
||||||
BanTrendResponse,
|
BanTrendResponse,
|
||||||
DashboardBanListResponse,
|
DashboardBanListResponse,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
@@ -206,3 +208,39 @@ async def get_ban_trend(
|
|||||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||||
|
|
||||||
return await ban_service.ban_trend(socket_path, range, origin=origin)
|
return await ban_service.ban_trend(socket_path, range, origin=origin)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/bans/by-jail",
|
||||||
|
response_model=BansByJailResponse,
|
||||||
|
summary="Return ban counts aggregated by jail",
|
||||||
|
)
|
||||||
|
async def get_bans_by_jail(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||||
|
origin: BanOrigin | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
|
),
|
||||||
|
) -> BansByJailResponse:
|
||||||
|
"""Return ban counts grouped by jail name for the selected time window.
|
||||||
|
|
||||||
|
Queries the fail2ban database and returns a list of jails sorted by
|
||||||
|
ban count descending. This endpoint is intended for the dashboard jail
|
||||||
|
distribution bar chart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming request (used to access ``app.state``).
|
||||||
|
_auth: Validated session dependency.
|
||||||
|
range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or
|
||||||
|
``"365d"``.
|
||||||
|
origin: Optional filter by ban origin.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.ban.BansByJailResponse` with per-jail counts
|
||||||
|
sorted descending and the total for the selected window.
|
||||||
|
"""
|
||||||
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||||
|
|
||||||
|
return await ban_service.bans_by_jail(socket_path, range, origin=origin)
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ from app.models.ban import (
|
|||||||
TIME_RANGE_SECONDS,
|
TIME_RANGE_SECONDS,
|
||||||
BanOrigin,
|
BanOrigin,
|
||||||
BansByCountryResponse,
|
BansByCountryResponse,
|
||||||
|
BansByJailResponse,
|
||||||
BanTrendBucket,
|
BanTrendBucket,
|
||||||
BanTrendResponse,
|
BanTrendResponse,
|
||||||
DashboardBanItem,
|
DashboardBanItem,
|
||||||
DashboardBanListResponse,
|
DashboardBanListResponse,
|
||||||
|
JailBanCount,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
_derive_origin,
|
_derive_origin,
|
||||||
bucket_count,
|
bucket_count,
|
||||||
@@ -573,3 +575,70 @@ async def ban_trend(
|
|||||||
buckets=buckets,
|
buckets=buckets,
|
||||||
bucket_size=BUCKET_SIZE_LABEL[range_],
|
bucket_size=BUCKET_SIZE_LABEL[range_],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# bans_by_jail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def bans_by_jail(
|
||||||
|
socket_path: str,
|
||||||
|
range_: TimeRange,
|
||||||
|
*,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
) -> BansByJailResponse:
|
||||||
|
"""Return ban counts aggregated per jail for the selected time window.
|
||||||
|
|
||||||
|
Queries the fail2ban database ``bans`` table, groups records by jail
|
||||||
|
name, and returns them ordered by count descending. The origin filter
|
||||||
|
is applied when provided so callers can restrict results to blocklist-
|
||||||
|
imported bans or organic fail2ban bans.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
|
||||||
|
``"365d"``).
|
||||||
|
origin: Optional origin filter — ``"blocklist"`` restricts to the
|
||||||
|
``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.ban.BansByJailResponse` with per-jail counts
|
||||||
|
sorted descending and the total ban count.
|
||||||
|
"""
|
||||||
|
since: int = _since_unix(range_)
|
||||||
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
|
|
||||||
|
db_path: str = await _get_fail2ban_db_path(socket_path)
|
||||||
|
log.info(
|
||||||
|
"ban_service_bans_by_jail",
|
||||||
|
db_path=db_path,
|
||||||
|
since=since,
|
||||||
|
range=range_,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 >= ?" + origin_clause,
|
||||||
|
(since, *origin_params),
|
||||||
|
) 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, COUNT(*) AS cnt "
|
||||||
|
"FROM bans "
|
||||||
|
"WHERE timeofban >= ?"
|
||||||
|
+ origin_clause
|
||||||
|
+ " GROUP BY jail ORDER BY cnt DESC",
|
||||||
|
(since, *origin_params),
|
||||||
|
) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
|
jails: list[JailBanCount] = [
|
||||||
|
JailBanCount(jail=str(row["jail"]), count=int(row["cnt"])) for row in rows
|
||||||
|
]
|
||||||
|
return BansByJailResponse(jails=jails, total=total)
|
||||||
|
|||||||
@@ -711,3 +711,138 @@ class TestBanTrend:
|
|||||||
assert body["buckets"] == []
|
assert body["buckets"] == []
|
||||||
assert body["bucket_size"] == "1h"
|
assert body["bucket_size"] == "1h"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bans by jail endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bans_by_jail_response() -> object:
|
||||||
|
"""Build a stub :class:`~app.models.ban.BansByJailResponse`."""
|
||||||
|
from app.models.ban import BansByJailResponse, JailBanCount
|
||||||
|
|
||||||
|
return BansByJailResponse(
|
||||||
|
jails=[
|
||||||
|
JailBanCount(jail="sshd", count=10),
|
||||||
|
JailBanCount(jail="nginx", count=5),
|
||||||
|
],
|
||||||
|
total=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
class TestBansByJail:
|
||||||
|
"""GET /api/dashboard/bans/by-jail."""
|
||||||
|
|
||||||
|
async def test_returns_200_when_authenticated(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Authenticated request returns HTTP 200."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.bans_by_jail",
|
||||||
|
new=AsyncMock(return_value=_make_bans_by_jail_response()),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/by-jail")
|
||||||
|
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/by-jail")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""Response body contains ``jails`` list and ``total`` integer."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.bans_by_jail",
|
||||||
|
new=AsyncMock(return_value=_make_bans_by_jail_response()),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/by-jail")
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
assert "jails" in body
|
||||||
|
assert "total" in body
|
||||||
|
assert isinstance(body["total"], int)
|
||||||
|
|
||||||
|
async def test_each_jail_has_name_and_count(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Every element of ``jails`` has ``jail`` (string) and ``count`` (int)."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.bans_by_jail",
|
||||||
|
new=AsyncMock(return_value=_make_bans_by_jail_response()),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/by-jail")
|
||||||
|
|
||||||
|
for entry in response.json()["jails"]:
|
||||||
|
assert "jail" in entry
|
||||||
|
assert "count" in entry
|
||||||
|
assert isinstance(entry["jail"], str)
|
||||||
|
assert isinstance(entry["count"], int)
|
||||||
|
|
||||||
|
async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""Omitting ``range`` defaults to ``"24h"``."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_bans_by_jail_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans/by-jail")
|
||||||
|
|
||||||
|
called_range = mock_fn.call_args[0][1]
|
||||||
|
assert called_range == "24h"
|
||||||
|
|
||||||
|
async def test_accepts_range_param(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""The ``range`` query parameter is forwarded to the service."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_bans_by_jail_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans/by-jail?range=7d")
|
||||||
|
|
||||||
|
called_range = mock_fn.call_args[0][1]
|
||||||
|
assert called_range == "7d"
|
||||||
|
|
||||||
|
async def test_origin_param_forwarded(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""``?origin=blocklist`` is passed as a keyword arg to the service."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_bans_by_jail_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn):
|
||||||
|
await dashboard_client.get(
|
||||||
|
"/api/dashboard/bans/by-jail?origin=blocklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, kwargs = mock_fn.call_args
|
||||||
|
assert kwargs.get("origin") == "blocklist"
|
||||||
|
|
||||||
|
async def test_no_origin_defaults_to_none(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Omitting ``origin`` passes ``None`` to the service."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_bans_by_jail_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans/by-jail")
|
||||||
|
|
||||||
|
_, kwargs = mock_fn.call_args
|
||||||
|
assert kwargs.get("origin") is None
|
||||||
|
|
||||||
|
async def test_invalid_range_returns_422(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""An invalid ``range`` value returns HTTP 422."""
|
||||||
|
response = await dashboard_client.get(
|
||||||
|
"/api/dashboard/bans/by-jail?range=invalid"
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""Empty jails list is serialised correctly."""
|
||||||
|
from app.models.ban import BansByJailResponse
|
||||||
|
|
||||||
|
empty = BansByJailResponse(jails=[], total=0)
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.bans_by_jail",
|
||||||
|
new=AsyncMock(return_value=empty),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/by-jail")
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
assert body["jails"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
|
||||||
|
|||||||
@@ -776,3 +776,130 @@ class TestBanTrend:
|
|||||||
parsed = datetime.fromisoformat(bucket.timestamp)
|
parsed = datetime.fromisoformat(bucket.timestamp)
|
||||||
assert parsed.tzinfo is not None # Must be timezone-aware (UTC)
|
assert parsed.tzinfo is not None # Must be timezone-aware (UTC)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# bans_by_jail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBansByJail:
|
||||||
|
"""Verify ban_service.bans_by_jail() behaviour."""
|
||||||
|
|
||||||
|
async def test_returns_jails_sorted_descending(self, tmp_path: Path) -> None:
|
||||||
|
"""Jails are returned ordered by count descending."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
now = int(_time.time())
|
||||||
|
one_hour_ago = now - 3600
|
||||||
|
path = str(tmp_path / "test_by_jail.sqlite3")
|
||||||
|
await _create_f2b_db(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{"jail": "sshd", "ip": "1.1.1.1", "timeofban": one_hour_ago},
|
||||||
|
{"jail": "sshd", "ip": "1.1.1.2", "timeofban": one_hour_ago},
|
||||||
|
{"jail": "nginx", "ip": "2.2.2.2", "timeofban": one_hour_ago},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_jail("/fake/sock", "24h")
|
||||||
|
|
||||||
|
assert result.jails[0].jail == "sshd"
|
||||||
|
assert result.jails[0].count == 2
|
||||||
|
assert result.jails[1].jail == "nginx"
|
||||||
|
assert result.jails[1].count == 1
|
||||||
|
|
||||||
|
async def test_total_equals_sum_of_counts(self, tmp_path: Path) -> None:
|
||||||
|
"""``total`` equals the sum of all per-jail counts."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
now = int(_time.time())
|
||||||
|
one_hour_ago = now - 3600
|
||||||
|
path = str(tmp_path / "test_by_jail_total.sqlite3")
|
||||||
|
await _create_f2b_db(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{"jail": "sshd", "ip": "1.1.1.1", "timeofban": one_hour_ago},
|
||||||
|
{"jail": "nginx", "ip": "2.2.2.2", "timeofban": one_hour_ago},
|
||||||
|
{"jail": "nginx", "ip": "3.3.3.3", "timeofban": one_hour_ago},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_jail("/fake/sock", "24h")
|
||||||
|
|
||||||
|
assert result.total == sum(j.count for j in result.jails)
|
||||||
|
assert result.total == 3
|
||||||
|
|
||||||
|
async def test_empty_db_returns_empty_list(self, empty_f2b_db_path: str) -> None:
|
||||||
|
"""An empty database returns an empty jails list with total zero."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_jail("/fake/sock", "24h")
|
||||||
|
|
||||||
|
assert result.jails == []
|
||||||
|
assert result.total == 0
|
||||||
|
|
||||||
|
async def test_excludes_bans_outside_time_window(self, f2b_db_path: str) -> None:
|
||||||
|
"""Bans older than the time window are not counted."""
|
||||||
|
# f2b_db_path has one ban from _TWO_DAYS_AGO, which is outside "24h".
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_jail("/fake/sock", "24h")
|
||||||
|
|
||||||
|
# Only 2 bans within 24h (both from _ONE_HOUR_AGO).
|
||||||
|
assert result.total == 2
|
||||||
|
|
||||||
|
async def test_origin_filter_blocklist(self, mixed_origin_db_path: str) -> None:
|
||||||
|
"""``origin='blocklist'`` returns only the blocklist-import jail."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_jail(
|
||||||
|
"/fake/sock", "24h", origin="blocklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.jails) == 1
|
||||||
|
assert result.jails[0].jail == "blocklist-import"
|
||||||
|
assert result.total == 1
|
||||||
|
|
||||||
|
async def test_origin_filter_selfblock(self, mixed_origin_db_path: str) -> None:
|
||||||
|
"""``origin='selfblock'`` excludes the blocklist-import jail."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_jail(
|
||||||
|
"/fake/sock", "24h", origin="selfblock"
|
||||||
|
)
|
||||||
|
|
||||||
|
jail_names = {j.jail for j in result.jails}
|
||||||
|
assert "blocklist-import" not in jail_names
|
||||||
|
assert result.total == 2
|
||||||
|
|
||||||
|
async def test_no_origin_filter_returns_all_jails(
|
||||||
|
self, mixed_origin_db_path: str
|
||||||
|
) -> None:
|
||||||
|
"""``origin=None`` returns bans from all jails."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=mixed_origin_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_jail(
|
||||||
|
"/fake/sock", "24h", origin=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 3
|
||||||
|
assert len(result.jails) == 3
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
import { get } from "./client";
|
import { get } from "./client";
|
||||||
import { ENDPOINTS } from "./endpoints";
|
import { ENDPOINTS } from "./endpoints";
|
||||||
import type {
|
import type {
|
||||||
BanTrendResponse,
|
|
||||||
BanOriginFilter,
|
BanOriginFilter,
|
||||||
|
BansByJailResponse,
|
||||||
|
BanTrendResponse,
|
||||||
DashboardBanListResponse,
|
DashboardBanListResponse,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
} from "../types/ban";
|
} from "../types/ban";
|
||||||
@@ -72,3 +73,23 @@ export async function fetchBanTrend(
|
|||||||
}
|
}
|
||||||
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
|
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch ban counts aggregated by jail for the selected time window.
|
||||||
|
*
|
||||||
|
* @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
|
||||||
|
* @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"`
|
||||||
|
* (default `"all"`, which omits the parameter entirely).
|
||||||
|
* @returns {@link BansByJailResponse} with jails sorted by ban count descending.
|
||||||
|
* @throws {ApiError} When the server returns a non-2xx status.
|
||||||
|
*/
|
||||||
|
export async function fetchBansByJail(
|
||||||
|
range: TimeRange,
|
||||||
|
origin: BanOriginFilter = "all",
|
||||||
|
): Promise<BansByJailResponse> {
|
||||||
|
const params = new URLSearchParams({ range });
|
||||||
|
if (origin !== "all") {
|
||||||
|
params.set("origin", origin);
|
||||||
|
}
|
||||||
|
return get<BansByJailResponse>(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const ENDPOINTS = {
|
|||||||
dashboardBans: "/dashboard/bans",
|
dashboardBans: "/dashboard/bans",
|
||||||
dashboardBansByCountry: "/dashboard/bans/by-country",
|
dashboardBansByCountry: "/dashboard/bans/by-country",
|
||||||
dashboardBansTrend: "/dashboard/bans/trend",
|
dashboardBansTrend: "/dashboard/bans/trend",
|
||||||
|
dashboardBansByJail: "/dashboard/bans/by-jail",
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Jails
|
// Jails
|
||||||
|
|||||||
218
frontend/src/components/JailDistributionChart.tsx
Normal file
218
frontend/src/components/JailDistributionChart.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* JailDistributionChart — horizontal bar chart showing ban counts per jail,
|
||||||
|
* sorted descending, for the selected time window.
|
||||||
|
*
|
||||||
|
* Calls `useJailDistribution` internally and handles loading, error, and
|
||||||
|
* empty states so the parent only needs to pass filter props.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||||
|
import {
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
tokens,
|
||||||
|
makeStyles,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import {
|
||||||
|
CHART_AXIS_TEXT_TOKEN,
|
||||||
|
CHART_GRID_LINE_TOKEN,
|
||||||
|
CHART_PALETTE,
|
||||||
|
resolveFluentToken,
|
||||||
|
} from "../utils/chartTheme";
|
||||||
|
import { useJailDistribution } from "../hooks/useJailDistribution";
|
||||||
|
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Maximum characters before truncating a jail name on the Y-axis. */
|
||||||
|
const MAX_LABEL_LENGTH = 24;
|
||||||
|
|
||||||
|
/** Height per bar row in pixels. */
|
||||||
|
const BAR_HEIGHT_PX = 36;
|
||||||
|
|
||||||
|
/** Minimum chart height in pixels. */
|
||||||
|
const MIN_CHART_HEIGHT = 180;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Props for {@link JailDistributionChart}. */
|
||||||
|
interface JailDistributionChartProps {
|
||||||
|
/** Time-range preset controlling the query window. */
|
||||||
|
timeRange: TimeRange;
|
||||||
|
/** Origin filter controlling which bans are included. */
|
||||||
|
origin: BanOriginFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal chart data point shape. */
|
||||||
|
interface BarEntry {
|
||||||
|
/** Full jail name used by Tooltip. */
|
||||||
|
fullName: string;
|
||||||
|
/** Truncated name displayed on the Y-axis. */
|
||||||
|
name: string;
|
||||||
|
/** Ban count. */
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Styles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
wrapper: {
|
||||||
|
width: "100%",
|
||||||
|
overflowX: "hidden",
|
||||||
|
},
|
||||||
|
stateWrapper: {
|
||||||
|
width: "100%",
|
||||||
|
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the chart dataset from the raw jail list.
|
||||||
|
*
|
||||||
|
* @param jails - Ordered list of `{jail, count}` items from the API.
|
||||||
|
* @returns Array of `BarEntry` objects ready for Recharts.
|
||||||
|
*/
|
||||||
|
function buildEntries(jails: Array<{ jail: string; count: number }>): BarEntry[] {
|
||||||
|
return jails.map(({ jail, count }) => ({
|
||||||
|
fullName: jail,
|
||||||
|
name: jail.length > MAX_LABEL_LENGTH ? `${jail.slice(0, MAX_LABEL_LENGTH)}…` : jail,
|
||||||
|
value: count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom tooltip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function JailTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
||||||
|
const { active, payload } = props;
|
||||||
|
if (!active || payload.length === 0) return null;
|
||||||
|
const entry = payload[0];
|
||||||
|
if (entry == null) return null;
|
||||||
|
|
||||||
|
const { fullName, value } = entry.payload as BarEntry;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
|
||||||
|
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
color: resolveFluentToken(tokens.colorNeutralForeground1),
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{fullName}</strong>
|
||||||
|
<br />
|
||||||
|
{String(value)} ban{value === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal bar chart showing ban counts per jail for the selected window.
|
||||||
|
*
|
||||||
|
* Fetches data via `useJailDistribution` and renders loading, error, and
|
||||||
|
* empty states inline.
|
||||||
|
*
|
||||||
|
* @param props - `timeRange` and `origin` filter props.
|
||||||
|
*/
|
||||||
|
export function JailDistributionChart({
|
||||||
|
timeRange,
|
||||||
|
origin,
|
||||||
|
}: JailDistributionChartProps): React.JSX.Element {
|
||||||
|
const styles = useStyles();
|
||||||
|
const { jails, isLoading, error } = useJailDistribution(timeRange, origin);
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
return (
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{error}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stateWrapper}>
|
||||||
|
<Spinner label="Loading chart data…" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jails.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stateWrapper}>
|
||||||
|
<Text className={styles.emptyText}>No ban data for the selected period.</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = buildEntries(jails);
|
||||||
|
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
|
||||||
|
|
||||||
|
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
|
||||||
|
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
|
||||||
|
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper} style={{ height: chartHeight }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
layout="vertical"
|
||||||
|
data={entries}
|
||||||
|
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fill: axisColour, fontSize: 12 }}
|
||||||
|
axisLine={{ stroke: gridColour }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={160}
|
||||||
|
tick={{ fill: axisColour, fontSize: 12 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip content={JailTooltip} cursor={{ fill: "transparent" }} />
|
||||||
|
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/hooks/useJailDistribution.ts
Normal file
85
frontend/src/hooks/useJailDistribution.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* `useJailDistribution` hook.
|
||||||
|
*
|
||||||
|
* Fetches per-jail ban counts for the jail distribution chart.
|
||||||
|
* Re-fetches automatically when `timeRange` or `origin` changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchBansByJail } from "../api/dashboard";
|
||||||
|
import type { BanOriginFilter, JailBanCount, TimeRange } from "../types/ban";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Return type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Return value shape for {@link useJailDistribution}. */
|
||||||
|
export interface UseJailDistributionResult {
|
||||||
|
/** Jails ordered by ban count descending. */
|
||||||
|
jails: JailBanCount[];
|
||||||
|
/** Total ban count for the selected window. */
|
||||||
|
total: number;
|
||||||
|
/** True while a fetch is in flight. */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Error message or `null`. */
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and expose per-jail ban counts for the `JailDistributionChart` component.
|
||||||
|
*
|
||||||
|
* @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
|
||||||
|
* @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`.
|
||||||
|
* @returns Jail list, total count, loading state, and error.
|
||||||
|
*/
|
||||||
|
export function useJailDistribution(
|
||||||
|
timeRange: TimeRange,
|
||||||
|
origin: BanOriginFilter,
|
||||||
|
): UseJailDistributionResult {
|
||||||
|
const [jails, setJails] = useState<JailBanCount[]>([]);
|
||||||
|
const [total, setTotal] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback((): void => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
fetchBansByJail(timeRange, origin)
|
||||||
|
.then((data) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setJails(data.jails);
|
||||||
|
setTotal(data.total);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch jail distribution",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [timeRange, origin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
return (): void => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { jails, total, isLoading, error };
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { BanTable } from "../components/BanTable";
|
import { BanTable } from "../components/BanTable";
|
||||||
import { BanTrendChart } from "../components/BanTrendChart";
|
import { BanTrendChart } from "../components/BanTrendChart";
|
||||||
|
import { JailDistributionChart } from "../components/JailDistributionChart";
|
||||||
import { ServerStatusBar } from "../components/ServerStatusBar";
|
import { ServerStatusBar } from "../components/ServerStatusBar";
|
||||||
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
|
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
|
||||||
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
|
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
|
||||||
@@ -166,6 +167,20 @@ export function DashboardPage(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ------------------------------------------------------------------ */}
|
||||||
|
{/* Jail Distribution section */}
|
||||||
|
{/* ------------------------------------------------------------------ */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<Text as="h2" size={500} weight="semibold">
|
||||||
|
Jail Distribution
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
<JailDistributionChart timeRange={timeRange} origin={originFilter} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* Ban list section */}
|
{/* Ban list section */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
|
|||||||
@@ -110,3 +110,31 @@ export interface BanTrendResponse {
|
|||||||
/** Human-readable bucket size label, e.g. `"1h"`, `"6h"`, `"1d"`, `"7d"`. */
|
/** Human-readable bucket size label, e.g. `"1h"`, `"6h"`, `"1d"`, `"7d"`. */
|
||||||
bucket_size: string;
|
bucket_size: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bans by jail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single jail entry in the bans-by-jail aggregation.
|
||||||
|
*
|
||||||
|
* Mirrors `JailBanCount` from `backend/app/models/ban.py`.
|
||||||
|
*/
|
||||||
|
export interface JailBanCount {
|
||||||
|
/** Jail name. */
|
||||||
|
jail: string;
|
||||||
|
/** Number of bans recorded in this jail for the selected window. */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from `GET /api/dashboard/bans/by-jail`.
|
||||||
|
*
|
||||||
|
* Mirrors `BansByJailResponse` from `backend/app/models/ban.py`.
|
||||||
|
*/
|
||||||
|
export interface BansByJailResponse {
|
||||||
|
/** Jails ordered by ban count descending. */
|
||||||
|
jails: JailBanCount[];
|
||||||
|
/** Total ban count in the selected window. */
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user