feature/ignore-self-toggle #1
@@ -289,7 +289,15 @@ class BanTrendResponse(BaseModel):
|
|||||||
|
|
||||||
### Task 4.2 — Create the `BanTrendChart` component
|
### 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.
|
Create `frontend/src/components/BanTrendChart.tsx`. This component renders an **area/line chart** showing the number of bans over time.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
|
|
||||||
import { get } from "./client";
|
import { get } from "./client";
|
||||||
import { ENDPOINTS } from "./endpoints";
|
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";
|
import type { ServerStatusResponse } from "../types/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,3 +53,22 @@ export async function fetchBans(
|
|||||||
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
|
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()}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const ENDPOINTS = {
|
|||||||
dashboardStatus: "/dashboard/status",
|
dashboardStatus: "/dashboard/status",
|
||||||
dashboardBans: "/dashboard/bans",
|
dashboardBans: "/dashboard/bans",
|
||||||
dashboardBansByCountry: "/dashboard/bans/by-country",
|
dashboardBansByCountry: "/dashboard/bans/by-country",
|
||||||
|
dashboardBansTrend: "/dashboard/bans/trend",
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Jails
|
// Jails
|
||||||
|
|||||||
270
frontend/src/components/BanTrendChart.tsx
Normal file
270
frontend/src/components/BanTrendChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/hooks/useBanTrend.ts
Normal file
83
frontend/src/hooks/useBanTrend.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -82,3 +82,31 @@ export interface DashboardBanListResponse {
|
|||||||
/** Maximum items per page. */
|
/** Maximum items per page. */
|
||||||
page_size: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user