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:
@@ -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()}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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";
|
||||
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 */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user