diff --git a/Docs/Tasks.md b/Docs/Tasks.md index ffd0295..ae242b9 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -289,7 +289,15 @@ class BanTrendResponse(BaseModel): ### Task 4.2 — Create the `BanTrendChart` component -**Status:** `not started` +**Status:** `done` + +Created `frontend/src/components/BanTrendChart.tsx` — an area chart using Recharts +`AreaChart` with a gradient fill, human-readable X-axis time labels (format varies by +time range), and a custom tooltip. Added `BanTrendBucket`/`BanTrendResponse` types to +`types/ban.ts`, `dashboardBansTrend` constant to `api/endpoints.ts`, `fetchBanTrend()` +to `api/dashboard.ts`, and the `useBanTrend` hook at `hooks/useBanTrend.ts`. Component +handles loading (Spinner), error (MessageBar), and empty states inline. +`tsc --noEmit` and ESLint pass with zero warnings. Create `frontend/src/components/BanTrendChart.tsx`. This component renders an **area/line chart** showing the number of bans over time. diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index f9cf011..6da9062 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -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(`${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 { + const params = new URLSearchParams({ range }); + if (origin !== "all") { + params.set("origin", origin); + } + return get(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`); +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index dec7163..926f7b6 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -30,6 +30,7 @@ export const ENDPOINTS = { dashboardStatus: "/dashboard/status", dashboardBans: "/dashboard/bans", dashboardBansByCountry: "/dashboard/bans/by-country", + dashboardBansTrend: "/dashboard/bans/trend", // ------------------------------------------------------------------------- // Jails diff --git a/frontend/src/components/BanTrendChart.tsx b/frontend/src/components/BanTrendChart.tsx new file mode 100644 index 0000000..a99aeeb --- /dev/null +++ b/frontend/src/components/BanTrendChart.tsx @@ -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 = { + "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 ( +
+ {label} +
+ {String(count)} ban{count === 1 ? "" : "s"} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + {error} + + ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const isEmpty = buckets.every((b) => b.count === 0); + if (isEmpty) { + return ( +
+ No bans in this time range. +
+ ); + } + + 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 ( +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/hooks/useBanTrend.ts b/frontend/src/hooks/useBanTrend.ts new file mode 100644 index 0000000..a839fe0 --- /dev/null +++ b/frontend/src/hooks/useBanTrend.ts @@ -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([]); + const [bucketSize, setBucketSize] = useState("1h"); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const abortRef = useRef(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 }; +} diff --git a/frontend/src/types/ban.ts b/frontend/src/types/ban.ts index 17593b6..7062d11 100644 --- a/frontend/src/types/ban.ts +++ b/frontend/src/types/ban.ts @@ -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; +}