From b7fbad0328e8fa700a4548bf440a720a0ee4c23d Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 21 Apr 2026 20:08:54 +0200 Subject: [PATCH] Add dashboard filter context to remove prop drilling --- Docs/Tasks.md | 6 +- frontend/src/components/BanTable.tsx | 15 ++++- frontend/src/components/BanTrendChart.tsx | 17 +++-- .../src/components/DashboardFilterBar.tsx | 27 +++++--- .../src/components/JailDistributionChart.tsx | 10 ++- frontend/src/pages/DashboardPage.tsx | 30 ++++----- .../src/providers/DashboardFilterProvider.tsx | 67 +++++++++++++++++++ .../DashboardFilterProvider.test.tsx | 31 +++++++++ 8 files changed, 164 insertions(+), 39 deletions(-) create mode 100644 frontend/src/providers/DashboardFilterProvider.tsx create mode 100644 frontend/src/providers/__tests__/DashboardFilterProvider.test.tsx diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 426986c..2c73187 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -518,7 +518,11 @@ Update `JailConfig` (and the corresponding `JailConfigUpdate` patch type) to use --- -### TASK-026 — Props drilling: introduce a Dashboard filter context +### TASK-026 — Props drilling: introduce a Dashboard filter context (done) + +**Where fixed:** `frontend/src/pages/DashboardPage.tsx`, `frontend/src/providers/DashboardFilterProvider.tsx`, `frontend/src/components/DashboardFilterBar.tsx`, `frontend/src/components/BanTrendChart.tsx`, `frontend/src/components/BanTable.tsx`, `frontend/src/components/JailDistributionChart.tsx` + +**Summary:** Added `DashboardFilterProvider` and `useDashboardFilters` context. `DashboardPage` now wraps its content with the provider, and `DashboardFilterBar`, `BanTrendChart`, `BanTable`, and `JailDistributionChart` consume filter state from context while still supporting explicit prop overrides. **Where found:** `frontend/src/pages/DashboardPage.tsx` passes `timeRange`, `originFilter`, and `source` individually as props to `BanTrendChart`, `BanTable`, `TopCountriesPieChart`, `TopCountriesBarChart`, and `JailDistributionChart`. diff --git a/frontend/src/components/BanTable.tsx b/frontend/src/components/BanTable.tsx index 0c69f14..6f4a5c1 100644 --- a/frontend/src/components/BanTable.tsx +++ b/frontend/src/components/BanTable.tsx @@ -9,6 +9,7 @@ */ import { memo, useMemo } from "react"; +import { useDashboardFilters } from "../providers/DashboardFilterProvider"; import { Badge, Button, @@ -41,7 +42,7 @@ interface BanTableProps { * Active time-range preset — controlled by the parent `DashboardPage`. * Changing this value triggers a re-fetch. */ - timeRange: TimeRange; + timeRange?: TimeRange; /** * Active origin filter — controlled by the parent `DashboardPage`. * Changing this value triggers a re-fetch and resets to page 1. @@ -191,9 +192,17 @@ function buildBanColumns(styles: ReturnType): TableColumnDefin * @param props.timeRange - Active time-range preset from the parent page. * @param props.origin - Active origin filter from the parent page. */ -export const BanTable = memo(function BanTable({ timeRange, origin = "all", source = "fail2ban" }: BanTableProps): React.JSX.Element { +export const BanTable = memo(function BanTable({ timeRange, origin, source }: BanTableProps): React.JSX.Element { const styles = useStyles(); - const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin, source); + const context = useDashboardFilters(); + const effectiveTimeRange = timeRange ?? context.timeRange; + const effectiveOrigin = origin ?? context.originFilter; + const effectiveSource = source ?? context.source; + const { banItems, total, page, setPage, loading, error, refresh } = useBans( + effectiveTimeRange, + effectiveOrigin, + effectiveSource, + ); const banColumns = useMemo(() => buildBanColumns(styles), [styles]); diff --git a/frontend/src/components/BanTrendChart.tsx b/frontend/src/components/BanTrendChart.tsx index 10baca2..5135cb2 100644 --- a/frontend/src/components/BanTrendChart.tsx +++ b/frontend/src/components/BanTrendChart.tsx @@ -5,6 +5,7 @@ */ import { memo, useMemo } from "react"; +import { useDashboardFilters } from "../providers/DashboardFilterProvider"; import { Area, AreaChart, @@ -52,9 +53,9 @@ const TICK_INTERVAL: Record = { /** Props for {@link BanTrendChart}. */ interface BanTrendChartProps { /** Time-range preset controlling the query window. */ - timeRange: TimeRange; + timeRange?: TimeRange; /** Origin filter controlling which bans are included. */ - origin: BanOriginFilter; + origin?: BanOriginFilter; /** Data source used for the chart. */ source?: "fail2ban" | "archive"; } @@ -198,14 +199,18 @@ function TrendTooltip(props: TrendTooltipProps): React.JSX.Element | null { export const BanTrendChart = memo(function BanTrendChart({ timeRange, origin, - source = "fail2ban", + source, }: BanTrendChartProps): React.JSX.Element { const styles = useStyles(); + const context = useDashboardFilters(); + const effectiveTimeRange = timeRange ?? context.timeRange; + const effectiveOrigin = origin ?? context.originFilter; + const effectiveSource = source ?? context.source; const { colorMode } = useThemeMode(); - const { buckets, loading, error, reload } = useBanTrend(timeRange, origin, source); + const { buckets, loading, error, reload } = useBanTrend(effectiveTimeRange, effectiveOrigin, effectiveSource); const isEmpty = buckets.every((b) => b.count === 0); - const entries = buildEntries(buckets, timeRange); + const entries = buildEntries(buckets, effectiveTimeRange); const { primaryColour, axisColour, gridColour } = useMemo( () => { void colorMode; @@ -217,7 +222,7 @@ export const BanTrendChart = memo(function BanTrendChart({ }, [colorMode], ); - const tickInterval = TICK_INTERVAL[timeRange]; + const tickInterval = TICK_INTERVAL[effectiveTimeRange]; const tooltipContent = useMemo( () => { diff --git a/frontend/src/components/DashboardFilterBar.tsx b/frontend/src/components/DashboardFilterBar.tsx index 1a9feae..0fff5a8 100644 --- a/frontend/src/components/DashboardFilterBar.tsx +++ b/frontend/src/components/DashboardFilterBar.tsx @@ -16,6 +16,7 @@ import { tokens, } from "@fluentui/react-components"; import { useCardStyles } from "../components/commonStyles"; +import { useDashboardFilters } from "../providers/DashboardFilterProvider"; import type { BanOriginFilter, TimeRange } from "../types/ban"; import { BAN_ORIGIN_FILTER_LABELS, @@ -29,13 +30,13 @@ import { /** Props for {@link DashboardFilterBar}. */ export interface DashboardFilterBarProps { /** Currently selected time-range preset. */ - timeRange: TimeRange; + timeRange?: TimeRange; /** Called when the user selects a different time-range preset. */ - onTimeRangeChange: (value: TimeRange) => void; + onTimeRangeChange?: (value: TimeRange) => void; /** Currently selected origin filter. */ - originFilter: BanOriginFilter; + originFilter?: BanOriginFilter; /** Called when the user selects a different origin filter. */ - onOriginFilterChange: (value: BanOriginFilter) => void; + onOriginFilterChange?: (value: BanOriginFilter) => void; /** Jail filter value (optional). */ jail?: string; /** Called when the jail filter text changes (optional). */ @@ -106,9 +107,15 @@ export function DashboardFilterBar({ ip, onIpChange, }: DashboardFilterBarProps): React.JSX.Element { + const context = useDashboardFilters(); const styles = useStyles(); const cardStyles = useCardStyles(); + const currentTimeRange = timeRange ?? context.timeRange; + const handleTimeRangeChange = onTimeRangeChange ?? context.setTimeRange; + const currentOriginFilter = originFilter ?? context.originFilter; + const handleOriginFilterChange = onOriginFilterChange ?? context.setOriginFilter; + return (
{/* Time-range group */} @@ -121,10 +128,10 @@ export function DashboardFilterBar({ { - onTimeRangeChange(r); + handleTimeRangeChange(r); }} > {TIME_RANGE_LABELS[r]} @@ -148,10 +155,10 @@ export function DashboardFilterBar({ { - onOriginFilterChange(f); + handleOriginFilterChange(f); }} > {BAN_ORIGIN_FILTER_LABELS[f]} diff --git a/frontend/src/components/JailDistributionChart.tsx b/frontend/src/components/JailDistributionChart.tsx index 77c542e..3892e39 100644 --- a/frontend/src/components/JailDistributionChart.tsx +++ b/frontend/src/components/JailDistributionChart.tsx @@ -24,6 +24,7 @@ import { CHART_PALETTE, resolveFluentToken, } from "../utils/chartTheme"; +import { useDashboardFilters } from "../providers/DashboardFilterProvider"; import { useThemeMode } from "../providers/ThemeProvider"; import { ChartStateWrapper } from "./ChartStateWrapper"; import { ChartTooltip } from "./ChartTooltip"; @@ -50,9 +51,9 @@ const MIN_CHART_HEIGHT = 180; /** Props for {@link JailDistributionChart}. */ interface JailDistributionChartProps { /** Time-range preset controlling the query window. */ - timeRange: TimeRange; + timeRange?: TimeRange; /** Origin filter controlling which bans are included. */ - origin: BanOriginFilter; + origin?: BanOriginFilter; } /** Internal chart data point shape. */ @@ -135,8 +136,11 @@ export const JailDistributionChart = memo(function JailDistributionChart({ origin, }: JailDistributionChartProps): React.JSX.Element { const styles = useStyles(); + const context = useDashboardFilters(); + const effectiveTimeRange = timeRange ?? context.timeRange; + const effectiveOrigin = origin ?? context.originFilter; const { colorMode } = useThemeMode(); - const { jails, loading, error, reload } = useJailDistribution(timeRange, origin); + const { jails, loading, error, reload } = useJailDistribution(effectiveTimeRange, effectiveOrigin); const entries = buildEntries(jails); const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 63a5815..6b46fac 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -6,7 +6,6 @@ * database. The time-range selection controls how far back to look. */ -import { useState } from "react"; import { Text, makeStyles, tokens } from "@fluentui/react-components"; import { BanTable } from "../components/BanTable"; import { BanTrendChart } from "../components/BanTrendChart"; @@ -17,8 +16,7 @@ import { TopCountriesBarChart } from "../components/TopCountriesBarChart"; import { TopCountriesPieChart } from "../components/TopCountriesPieChart"; import { useCommonSectionStyles } from "../components/commonStyles"; import { useDashboardCountryData } from "../hooks/useDashboardCountryData"; -import { getDataSource } from "../utils/queryUtils"; -import type { BanOriginFilter, TimeRange } from "../types/ban"; +import { DashboardFilterProvider, useDashboardFilters } from "../providers/DashboardFilterProvider"; // --------------------------------------------------------------------------- @@ -73,12 +71,9 @@ const useStyles = makeStyles({ * Displays the fail2ban server status, a time-range selector, and the * ban list table. */ -export function DashboardPage(): React.JSX.Element { +function DashboardPageContent(): React.JSX.Element { const styles = useStyles(); - const [timeRange, setTimeRange] = useState("24h"); - const [originFilter, setOriginFilter] = useState("all"); - - const source = getDataSource(timeRange); + const { timeRange, originFilter, source } = useDashboardFilters(); const { countries, countryNames, loading: countryLoading, error: countryError, reload: reloadCountry } = useDashboardCountryData(timeRange, originFilter, source); @@ -96,12 +91,7 @@ export function DashboardPage(): React.JSX.Element { {/* Global filter bar */} {/* ------------------------------------------------------------------ */}
- +
{/* ------------------------------------------------------------------ */} @@ -114,7 +104,7 @@ export function DashboardPage(): React.JSX.Element {
- +
@@ -165,10 +155,18 @@ export function DashboardPage(): React.JSX.Element { {/* Ban table */}
- +
); } +export function DashboardPage(): React.JSX.Element { + return ( + + + + ); +} + diff --git a/frontend/src/providers/DashboardFilterProvider.tsx b/frontend/src/providers/DashboardFilterProvider.tsx new file mode 100644 index 0000000..f18107b --- /dev/null +++ b/frontend/src/providers/DashboardFilterProvider.tsx @@ -0,0 +1,67 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { getDataSource } from "../utils/queryUtils"; +import type { BanOriginFilter, TimeRange } from "../types/ban"; + +interface DashboardFilterContextValue { + /** Currently selected dashboard time-range preset. */ + timeRange: TimeRange; + /** Currently selected dashboard origin filter. */ + originFilter: BanOriginFilter; + /** Derived data source for the active time-range. */ + source: "fail2ban" | "archive"; + /** Update the selected time-range preset. */ + setTimeRange: (value: TimeRange) => void; + /** Update the selected origin filter. */ + setOriginFilter: (value: BanOriginFilter) => void; +} + +const DashboardFilterContext = createContext(null); + +const DEFAULT_FILTERS: DashboardFilterContextValue = { + timeRange: "24h", + originFilter: "all", + source: "fail2ban", + setTimeRange: (): void => { + // no-op when no provider is present + }, + setOriginFilter: (): void => { + // no-op when no provider is present + }, +}; + +interface DashboardFilterProviderProps { + children: ReactNode; +} + +export function DashboardFilterProvider({ children }: DashboardFilterProviderProps): React.JSX.Element { + const [timeRange, setTimeRange] = useState(DEFAULT_FILTERS.timeRange); + const [originFilter, setOriginFilter] = useState(DEFAULT_FILTERS.originFilter); + + const setTimeRangeCallback = useCallback((value: TimeRange): void => { + setTimeRange(value); + }, []); + + const setOriginFilterCallback = useCallback((value: BanOriginFilter): void => { + setOriginFilter(value); + }, []); + + const source = useMemo(() => getDataSource(timeRange), [timeRange]); + + const value = useMemo( + () => ({ + timeRange, + originFilter, + source, + setTimeRange: setTimeRangeCallback, + setOriginFilter: setOriginFilterCallback, + }), + [timeRange, originFilter, source, setTimeRangeCallback, setOriginFilterCallback], + ); + + return {children}; +} + +export function useDashboardFilters(): DashboardFilterContextValue { + const context = useContext(DashboardFilterContext); + return context ?? DEFAULT_FILTERS; +} diff --git a/frontend/src/providers/__tests__/DashboardFilterProvider.test.tsx b/frontend/src/providers/__tests__/DashboardFilterProvider.test.tsx new file mode 100644 index 0000000..e6ddc1c --- /dev/null +++ b/frontend/src/providers/__tests__/DashboardFilterProvider.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DashboardFilterBar } from "../../components/DashboardFilterBar"; +import { DashboardFilterProvider, useDashboardFilters } from "../DashboardFilterProvider"; + +function DashboardFilterSummary(): React.JSX.Element { + const { timeRange, originFilter } = useDashboardFilters(); + return
{`${timeRange}-${originFilter}`}
; +} + +describe("DashboardFilterProvider", () => { + it("provides filter state to DashboardFilterBar and updates when the user clicks", async () => { + const user = userEvent.setup(); + + render( + + + + , + ); + + expect(screen.getByTestId("dashboard-filters")).toHaveTextContent("24h-all"); + + await user.click(screen.getByRole("button", { name: /Last 7 days/i })); + expect(screen.getByTestId("dashboard-filters")).toHaveTextContent("7d-all"); + + await user.click(screen.getByRole("button", { name: /Blocklist/i })); + expect(screen.getByTestId("dashboard-filters")).toHaveTextContent("7d-blocklist"); + }); +});