Add jail distribution chart (Stage 5)

- backend: GET /api/dashboard/bans/by-jail endpoint
  - JailBanCount + BansByJailResponse Pydantic models in ban.py
  - bans_by_jail() service function with origin filter support
  - Route added to dashboard router
  - 17 new tests (7 service, 10 router); full suite 497 passed, 83% coverage

- frontend: JailDistributionChart component
  - JailBanCount / BansByJailResponse types in types/ban.ts
  - dashboardBansByJail endpoint constant in api/endpoints.ts
  - fetchBansByJail() in api/dashboard.ts
  - useJailDistribution hook in hooks/useJailDistribution.ts
  - JailDistributionChart component (horizontal bar chart, Recharts)
  - DashboardPage: full-width Jail Distribution section below Top Countries
This commit is contained in:
2026-03-11 17:01:19 +01:00
parent df0528b2c2
commit fe8eefa173
13 changed files with 799 additions and 6 deletions

View File

@@ -123,6 +123,7 @@ backend/
│ │ └── import_log_repo.py # Import run history records
│ ├── tasks/ # APScheduler background jobs
│ │ ├── 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
│ └── utils/ # Helpers, constants, shared types
│ ├── 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 |
|---|---|
| `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 |
#### 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) |
| `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) |
| `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`).
- 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.
- **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) │
├──────────────────────┤
│ blocklist_import │ ── runs on configured schedule (default: daily 03:00)
│ geo_cache_flush │ ── runs every 60 seconds
│ health_check │ ── runs every 30 seconds
└──────────────────────┘
```

View File

@@ -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
**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.
@@ -395,7 +403,16 @@ class BansByJailResponse(BaseModel):
### 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.
@@ -422,7 +439,14 @@ Create `frontend/src/components/JailDistributionChart.tsx`. This component rende
### 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.

View File

@@ -280,3 +280,29 @@ class BanTrendResponse(BaseModel):
...,
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.")

View File

@@ -5,8 +5,9 @@ fail2ban server health snapshot. The snapshot is maintained by the
background health-check task and refreshed every 30 seconds.
Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
``GET /api/dashboard/bans/by-country`` for country aggregation, and
``GET /api/dashboard/bans/trend`` for time-bucketed ban counts.
``GET /api/dashboard/bans/by-country`` for country aggregation,
``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
@@ -22,6 +23,7 @@ from app.dependencies import AuthDep
from app.models.ban import (
BanOrigin,
BansByCountryResponse,
BansByJailResponse,
BanTrendResponse,
DashboardBanListResponse,
TimeRange,
@@ -206,3 +208,39 @@ async def get_ban_trend(
socket_path: str = request.app.state.settings.fail2ban_socket
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)

View File

@@ -24,10 +24,12 @@ from app.models.ban import (
TIME_RANGE_SECONDS,
BanOrigin,
BansByCountryResponse,
BansByJailResponse,
BanTrendBucket,
BanTrendResponse,
DashboardBanItem,
DashboardBanListResponse,
JailBanCount,
TimeRange,
_derive_origin,
bucket_count,
@@ -573,3 +575,70 @@ async def ban_trend(
buckets=buckets,
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)

View File

@@ -711,3 +711,138 @@ class TestBanTrend:
assert body["buckets"] == []
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

View File

@@ -776,3 +776,130 @@ class TestBanTrend:
parsed = datetime.fromisoformat(bucket.timestamp)
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

View File

@@ -7,8 +7,9 @@
import { get } from "./client";
import { ENDPOINTS } from "./endpoints";
import type {
BanTrendResponse,
BanOriginFilter,
BansByJailResponse,
BanTrendResponse,
DashboardBanListResponse,
TimeRange,
} from "../types/ban";
@@ -72,3 +73,23 @@ export async function fetchBanTrend(
}
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()}`);
}

View File

@@ -31,6 +31,7 @@ export const ENDPOINTS = {
dashboardBans: "/dashboard/bans",
dashboardBansByCountry: "/dashboard/bans/by-country",
dashboardBansTrend: "/dashboard/bans/trend",
dashboardBansByJail: "/dashboard/bans/by-jail",
// -------------------------------------------------------------------------
// Jails

View 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>
);
}

View 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 };
}

View File

@@ -19,6 +19,7 @@ import {
} from "@fluentui/react-components";
import { BanTable } from "../components/BanTable";
import { BanTrendChart } from "../components/BanTrendChart";
import { JailDistributionChart } from "../components/JailDistributionChart";
import { ServerStatusBar } from "../components/ServerStatusBar";
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
@@ -166,6 +167,20 @@ export function DashboardPage(): React.JSX.Element {
</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 */}
{/* ------------------------------------------------------------------ */}

View File

@@ -110,3 +110,31 @@ export interface BanTrendResponse {
/** Human-readable bucket size label, e.g. `"1h"`, `"6h"`, `"1d"`, `"7d"`. */
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;
}