Add BanTrendChart component and useBanTrend hook

- Add BanTrendBucket / BanTrendResponse interfaces to types/ban.ts
- Add dashboardBansTrend endpoint constant to api/endpoints.ts
- Add fetchBanTrend() to api/dashboard.ts
- Create useBanTrend hook with abort-safe data fetching
- Create BanTrendChart: AreaChart with gradient fill, dynamic
  X-axis labels per range, custom tooltip, loading/error/empty states
- tsc --noEmit and ESLint pass with zero warnings
This commit is contained in:
2026-03-11 16:48:49 +01:00
parent 9242b4709a
commit 259ff17eba
6 changed files with 416 additions and 2 deletions

View File

@@ -6,7 +6,12 @@
import { get } from "./client";
import { ENDPOINTS } from "./endpoints";
import type { DashboardBanListResponse, TimeRange, BanOriginFilter } from "../types/ban";
import type {
BanTrendResponse,
BanOriginFilter,
DashboardBanListResponse,
TimeRange,
} from "../types/ban";
import type { ServerStatusResponse } from "../types/server";
/**
@@ -48,3 +53,22 @@ export async function fetchBans(
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
}
/**
* Fetch ban counts grouped into equal-width time buckets for the trend chart.
*
* @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 BanTrendResponse} with the ordered bucket list.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchBanTrend(
range: TimeRange,
origin: BanOriginFilter = "all",
): Promise<BanTrendResponse> {
const params = new URLSearchParams({ range });
if (origin !== "all") {
params.set("origin", origin);
}
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
}

View File

@@ -30,6 +30,7 @@ export const ENDPOINTS = {
dashboardStatus: "/dashboard/status",
dashboardBans: "/dashboard/bans",
dashboardBansByCountry: "/dashboard/bans/by-country",
dashboardBansTrend: "/dashboard/bans/trend",
// -------------------------------------------------------------------------
// Jails

View File

@@ -0,0 +1,270 @@
/**
* BanTrendChart — area chart showing the number of bans over time.
*
* Calls `useBanTrend` internally and handles loading, error, and empty states.
*/
import {
Area,
AreaChart,
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 { useBanTrend } from "../hooks/useBanTrend";
import type { BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Minimum chart height in pixels. */
const MIN_CHART_HEIGHT = 220;
/** Number of X-axis ticks to skip per time-range preset. */
const TICK_INTERVAL: Record<TimeRange, number> = {
"24h": 3, // show every 4th tick → ~6 visible
"7d": 3, // show every 4th tick → 7 visible
"30d": 4, // show every 5th tick → 6 visible
"365d": 7, // show every 8th tick → ~7 visible
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props for {@link BanTrendChart}. */
interface BanTrendChartProps {
/** Time-range preset controlling the query window. */
timeRange: TimeRange;
/** Origin filter controlling which bans are included. */
origin: BanOriginFilter;
}
/** Internal chart data point shape. */
interface TrendEntry {
/** ISO 8601 UTC timestamp — used by the tooltip. */
timestamp: string;
/** Formatted string shown on the X-axis tick. */
label: string;
/** Number of bans in this bucket. */
count: number;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
wrapper: {
width: "100%",
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
chartWrapper: {
width: "100%",
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Format an ISO 8601 timestamp as a compact human-readable X-axis label.
*
* @param timestamp - ISO 8601 UTC string.
* @param range - Active time-range preset.
* @returns Label string, e.g. `"Mon 14:00"`, `"Mar 5"`.
*/
function formatXLabel(timestamp: string, range: TimeRange): string {
const d = new Date(timestamp);
if (range === "24h") {
const day = d.toLocaleDateString("en-US", { weekday: "short" });
const time = d.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return `${day} ${time}`;
}
if (range === "7d") {
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const time = d.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return `${date} ${time}`;
}
// 30d / 365d
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
/**
* Build the chart data array from the raw bucket list.
*
* @param buckets - Ordered list of `{timestamp, count}` buckets.
* @param timeRange - Active preset, used to format axis labels.
* @returns Array of `TrendEntry` objects ready for Recharts.
*/
function buildEntries(
buckets: Array<{ timestamp: string; count: number }>,
timeRange: TimeRange,
): TrendEntry[] {
return buckets.map((b) => ({
timestamp: b.timestamp,
label: formatXLabel(b.timestamp, timeRange),
count: b.count,
}));
}
// ---------------------------------------------------------------------------
// Custom tooltip
// ---------------------------------------------------------------------------
function TrendTooltip(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 { timestamp, count } = entry.payload as TrendEntry;
const d = new Date(timestamp);
const label = d.toLocaleString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
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>{label}</strong>
<br />
{String(count)} ban{count === 1 ? "" : "s"}
</div>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Area chart showing ban counts over time.
*
* Fetches data via `useBanTrend` and handles loading, error, and empty states
* inline so the parent only needs to pass filter props.
*
* @param props - `timeRange` and `origin` filter props.
*/
export function BanTrendChart({
timeRange,
origin,
}: BanTrendChartProps): React.JSX.Element {
const styles = useStyles();
const { buckets, isLoading, error } = useBanTrend(timeRange, origin);
if (error != null) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (isLoading) {
return (
<div className={styles.wrapper}>
<Spinner label="Loading trend data…" />
</div>
);
}
const isEmpty = buckets.every((b) => b.count === 0);
if (isEmpty) {
return (
<div className={styles.wrapper}>
<Text className={styles.emptyText}>No bans in this time range.</Text>
</div>
);
}
const entries = buildEntries(buckets, timeRange);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
const tickInterval = TICK_INTERVAL[timeRange];
return (
<div className={styles.chartWrapper} style={{ height: MIN_CHART_HEIGHT }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={entries} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="trendAreaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={primaryColour} stopOpacity={0.4} />
<stop offset="95%" stopColor={primaryColour} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} />
<XAxis
dataKey="label"
tick={{ fill: axisColour, fontSize: 11 }}
interval={tickInterval}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fill: axisColour, fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={TrendTooltip} />
<Area
type="monotone"
dataKey="count"
stroke={primaryColour}
strokeWidth={2}
fill="url(#trendAreaFill)"
dot={false}
activeDot={{ r: 4, fill: primaryColour }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,83 @@
/**
* `useBanTrend` hook.
*
* Fetches time-bucketed ban counts for the trend chart.
* Re-fetches automatically when `timeRange` or `origin` changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBanTrend } from "../api/dashboard";
import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
/** Return value shape for {@link useBanTrend}. */
export interface UseBanTrendResult {
/** Ordered list of time buckets with ban counts. */
buckets: BanTrendBucket[];
/** Human-readable bucket size label, e.g. `"1h"`. */
bucketSize: string;
/** True while a fetch is in flight. */
isLoading: boolean;
/** Error message or `null`. */
error: string | null;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Fetch and expose ban trend data for the `BanTrendChart` component.
*
* @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
* @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`.
* @returns Ordered bucket list, bucket-size label, loading state, and error.
*/
export function useBanTrend(
timeRange: TimeRange,
origin: BanOriginFilter,
): UseBanTrendResult {
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
const [bucketSize, setBucketSize] = useState<string>("1h");
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);
fetchBanTrend(timeRange, origin)
.then((data) => {
if (controller.signal.aborted) return;
setBuckets(data.buckets);
setBucketSize(data.bucket_size);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to fetch trend data");
})
.finally(() => {
if (!controller.signal.aborted) {
setIsLoading(false);
}
});
}, [timeRange, origin]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { buckets, bucketSize, isLoading, error };
}

View File

@@ -82,3 +82,31 @@ export interface DashboardBanListResponse {
/** Maximum items per page. */
page_size: number;
}
// ---------------------------------------------------------------------------
// Ban trend
// ---------------------------------------------------------------------------
/**
* A single time bucket in the ban trend series.
*
* Mirrors `BanTrendBucket` from `backend/app/models/ban.py`.
*/
export interface BanTrendBucket {
/** ISO 8601 UTC start of the bucket. */
timestamp: string;
/** Number of bans that started in this bucket. */
count: number;
}
/**
* Response from `GET /api/dashboard/bans/trend`.
*
* Mirrors `BanTrendResponse` from `backend/app/models/ban.py`.
*/
export interface BanTrendResponse {
/** Time-ordered list of ban-count buckets covering the full window. */
buckets: BanTrendBucket[];
/** Human-readable bucket size label, e.g. `"1h"`, `"6h"`, `"1d"`, `"7d"`. */
bucket_size: string;
}