284 lines
8.4 KiB
TypeScript
284 lines
8.4 KiB
TypeScript
/**
|
|
* 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<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;
|
|
/** 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<TooltipContentProps> {
|
|
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 (
|
|
<div
|
|
style={{
|
|
backgroundColor,
|
|
border: `1px solid ${borderColor}`,
|
|
borderRadius: "4px",
|
|
padding: "8px 12px",
|
|
color: textColor,
|
|
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.
|
|
*
|
|
/**
|
|
* 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 (
|
|
<TrendTooltip
|
|
backgroundColor={resolveFluentToken(tokens.colorNeutralBackground1)}
|
|
borderColor={resolveFluentToken(tokens.colorNeutralStroke2)}
|
|
textColor={resolveFluentToken(tokens.colorNeutralForeground1)}
|
|
/>
|
|
);
|
|
},
|
|
[colorMode],
|
|
);
|
|
|
|
return (
|
|
<ChartStateWrapper
|
|
isLoading={loading}
|
|
error={error}
|
|
onRetry={reload}
|
|
isEmpty={isEmpty}
|
|
emptyMessage="No bans in this time range."
|
|
minHeight={MIN_CHART_HEIGHT}
|
|
>
|
|
<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={tooltipContent} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="count"
|
|
stroke={primaryColour}
|
|
strokeWidth={2}
|
|
fill="url(#trendAreaFill)"
|
|
dot={false}
|
|
activeDot={{ r: 4, fill: primaryColour }}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</ChartStateWrapper>
|
|
);
|
|
});
|
|
|