/** * BanTrendChart — area chart showing the number of bans over time. * * Calls `useBanTrend` internally and handles loading, error, and empty states. */ import { memo, useMemo } from "react"; import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import type { TooltipContentProps } from "recharts/types/component/Tooltip"; import { tokens, makeStyles, } from "@fluentui/react-components"; import { CHART_AXIS_TEXT_TOKEN, CHART_GRID_LINE_TOKEN, CHART_PALETTE, resolveFluentToken, } from "../utils/chartTheme"; import { useThemeMode } from "../providers/ThemeProvider"; import { ChartStateWrapper } from "./ChartStateWrapper"; 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; /** Data source used for the chart. */ source?: "fail2ban" | "archive"; } /** 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({ chartWrapper: { width: "100%", }, }); // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- interface TrendTooltipProps extends Partial { backgroundColor: string; borderColor: string; textColor: string; } function TrendTooltip(props: TrendTooltipProps): React.JSX.Element | null { const { active, payload = [], backgroundColor, borderColor, textColor } = 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. * /** * Area chart showing ban counts over time. * * Fetches data via `useBanTrend` and delegates loading, error, and empty states * to `ChartStateWrapper`. * * @param props - `timeRange` and `origin` filter props. */ export const BanTrendChart = memo(function BanTrendChart({ timeRange, origin, source = "fail2ban", }: BanTrendChartProps): React.JSX.Element { const styles = useStyles(); const { colorMode } = useThemeMode(); const { buckets, loading, error, reload } = useBanTrend(timeRange, origin, source); const isEmpty = buckets.every((b) => b.count === 0); const entries = buildEntries(buckets, timeRange); const { primaryColour, axisColour, gridColour } = useMemo( () => { void colorMode; return { primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), }; }, [colorMode], ); const tickInterval = TICK_INTERVAL[timeRange]; const tooltipContent = useMemo( () => { void colorMode; return ( ); }, [colorMode], ); return (
); });