Add dashboard filter context to remove prop drilling

This commit is contained in:
2026-04-21 20:08:54 +02:00
parent b6d9c649ca
commit b7fbad0328
8 changed files with 164 additions and 39 deletions

View File

@@ -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`. **Where found:** `frontend/src/pages/DashboardPage.tsx` passes `timeRange`, `originFilter`, and `source` individually as props to `BanTrendChart`, `BanTable`, `TopCountriesPieChart`, `TopCountriesBarChart`, and `JailDistributionChart`.

View File

@@ -9,6 +9,7 @@
*/ */
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { useDashboardFilters } from "../providers/DashboardFilterProvider";
import { import {
Badge, Badge,
Button, Button,
@@ -41,7 +42,7 @@ interface BanTableProps {
* Active time-range preset — controlled by the parent `DashboardPage`. * Active time-range preset — controlled by the parent `DashboardPage`.
* Changing this value triggers a re-fetch. * Changing this value triggers a re-fetch.
*/ */
timeRange: TimeRange; timeRange?: TimeRange;
/** /**
* Active origin filter — controlled by the parent `DashboardPage`. * Active origin filter — controlled by the parent `DashboardPage`.
* Changing this value triggers a re-fetch and resets to page 1. * Changing this value triggers a re-fetch and resets to page 1.
@@ -191,9 +192,17 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
* @param props.timeRange - Active time-range preset from the parent page. * @param props.timeRange - Active time-range preset from the parent page.
* @param props.origin - Active origin filter 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 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]); const banColumns = useMemo(() => buildBanColumns(styles), [styles]);

View File

@@ -5,6 +5,7 @@
*/ */
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { useDashboardFilters } from "../providers/DashboardFilterProvider";
import { import {
Area, Area,
AreaChart, AreaChart,
@@ -52,9 +53,9 @@ const TICK_INTERVAL: Record<TimeRange, number> = {
/** Props for {@link BanTrendChart}. */ /** Props for {@link BanTrendChart}. */
interface BanTrendChartProps { interface BanTrendChartProps {
/** Time-range preset controlling the query window. */ /** Time-range preset controlling the query window. */
timeRange: TimeRange; timeRange?: TimeRange;
/** Origin filter controlling which bans are included. */ /** Origin filter controlling which bans are included. */
origin: BanOriginFilter; origin?: BanOriginFilter;
/** Data source used for the chart. */ /** Data source used for the chart. */
source?: "fail2ban" | "archive"; source?: "fail2ban" | "archive";
} }
@@ -198,14 +199,18 @@ function TrendTooltip(props: TrendTooltipProps): React.JSX.Element | null {
export const BanTrendChart = memo(function BanTrendChart({ export const BanTrendChart = memo(function BanTrendChart({
timeRange, timeRange,
origin, origin,
source = "fail2ban", source,
}: BanTrendChartProps): React.JSX.Element { }: BanTrendChartProps): React.JSX.Element {
const styles = useStyles(); 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 { 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 isEmpty = buckets.every((b) => b.count === 0);
const entries = buildEntries(buckets, timeRange); const entries = buildEntries(buckets, effectiveTimeRange);
const { primaryColour, axisColour, gridColour } = useMemo( const { primaryColour, axisColour, gridColour } = useMemo(
() => { () => {
void colorMode; void colorMode;
@@ -217,7 +222,7 @@ export const BanTrendChart = memo(function BanTrendChart({
}, },
[colorMode], [colorMode],
); );
const tickInterval = TICK_INTERVAL[timeRange]; const tickInterval = TICK_INTERVAL[effectiveTimeRange];
const tooltipContent = useMemo( const tooltipContent = useMemo(
() => { () => {

View File

@@ -16,6 +16,7 @@ import {
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { useCardStyles } from "../components/commonStyles"; import { useCardStyles } from "../components/commonStyles";
import { useDashboardFilters } from "../providers/DashboardFilterProvider";
import type { BanOriginFilter, TimeRange } from "../types/ban"; import type { BanOriginFilter, TimeRange } from "../types/ban";
import { import {
BAN_ORIGIN_FILTER_LABELS, BAN_ORIGIN_FILTER_LABELS,
@@ -29,13 +30,13 @@ import {
/** Props for {@link DashboardFilterBar}. */ /** Props for {@link DashboardFilterBar}. */
export interface DashboardFilterBarProps { export interface DashboardFilterBarProps {
/** Currently selected time-range preset. */ /** Currently selected time-range preset. */
timeRange: TimeRange; timeRange?: TimeRange;
/** Called when the user selects a different time-range preset. */ /** Called when the user selects a different time-range preset. */
onTimeRangeChange: (value: TimeRange) => void; onTimeRangeChange?: (value: TimeRange) => void;
/** Currently selected origin filter. */ /** Currently selected origin filter. */
originFilter: BanOriginFilter; originFilter?: BanOriginFilter;
/** Called when the user selects a different origin filter. */ /** Called when the user selects a different origin filter. */
onOriginFilterChange: (value: BanOriginFilter) => void; onOriginFilterChange?: (value: BanOriginFilter) => void;
/** Jail filter value (optional). */ /** Jail filter value (optional). */
jail?: string; jail?: string;
/** Called when the jail filter text changes (optional). */ /** Called when the jail filter text changes (optional). */
@@ -106,9 +107,15 @@ export function DashboardFilterBar({
ip, ip,
onIpChange, onIpChange,
}: DashboardFilterBarProps): React.JSX.Element { }: DashboardFilterBarProps): React.JSX.Element {
const context = useDashboardFilters();
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles(); const cardStyles = useCardStyles();
const currentTimeRange = timeRange ?? context.timeRange;
const handleTimeRangeChange = onTimeRangeChange ?? context.setTimeRange;
const currentOriginFilter = originFilter ?? context.originFilter;
const handleOriginFilterChange = onOriginFilterChange ?? context.setOriginFilter;
return ( return (
<div className={`${styles.container} ${cardStyles.card}`}> <div className={`${styles.container} ${cardStyles.card}`}>
{/* Time-range group */} {/* Time-range group */}
@@ -121,10 +128,10 @@ export function DashboardFilterBar({
<ToggleButton <ToggleButton
key={r} key={r}
size="small" size="small"
checked={timeRange === r} checked={currentTimeRange === r}
aria-pressed={timeRange === r} aria-pressed={currentTimeRange === r}
onClick={() => { onClick={() => {
onTimeRangeChange(r); handleTimeRangeChange(r);
}} }}
> >
{TIME_RANGE_LABELS[r]} {TIME_RANGE_LABELS[r]}
@@ -148,10 +155,10 @@ export function DashboardFilterBar({
<ToggleButton <ToggleButton
key={f} key={f}
size="small" size="small"
checked={originFilter === f} checked={currentOriginFilter === f}
aria-pressed={originFilter === f} aria-pressed={currentOriginFilter === f}
onClick={() => { onClick={() => {
onOriginFilterChange(f); handleOriginFilterChange(f);
}} }}
> >
{BAN_ORIGIN_FILTER_LABELS[f]} {BAN_ORIGIN_FILTER_LABELS[f]}

View File

@@ -24,6 +24,7 @@ import {
CHART_PALETTE, CHART_PALETTE,
resolveFluentToken, resolveFluentToken,
} from "../utils/chartTheme"; } from "../utils/chartTheme";
import { useDashboardFilters } from "../providers/DashboardFilterProvider";
import { useThemeMode } from "../providers/ThemeProvider"; import { useThemeMode } from "../providers/ThemeProvider";
import { ChartStateWrapper } from "./ChartStateWrapper"; import { ChartStateWrapper } from "./ChartStateWrapper";
import { ChartTooltip } from "./ChartTooltip"; import { ChartTooltip } from "./ChartTooltip";
@@ -50,9 +51,9 @@ const MIN_CHART_HEIGHT = 180;
/** Props for {@link JailDistributionChart}. */ /** Props for {@link JailDistributionChart}. */
interface JailDistributionChartProps { interface JailDistributionChartProps {
/** Time-range preset controlling the query window. */ /** Time-range preset controlling the query window. */
timeRange: TimeRange; timeRange?: TimeRange;
/** Origin filter controlling which bans are included. */ /** Origin filter controlling which bans are included. */
origin: BanOriginFilter; origin?: BanOriginFilter;
} }
/** Internal chart data point shape. */ /** Internal chart data point shape. */
@@ -135,8 +136,11 @@ export const JailDistributionChart = memo(function JailDistributionChart({
origin, origin,
}: JailDistributionChartProps): React.JSX.Element { }: JailDistributionChartProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const context = useDashboardFilters();
const effectiveTimeRange = timeRange ?? context.timeRange;
const effectiveOrigin = origin ?? context.originFilter;
const { colorMode } = useThemeMode(); const { colorMode } = useThemeMode();
const { jails, loading, error, reload } = useJailDistribution(timeRange, origin); const { jails, loading, error, reload } = useJailDistribution(effectiveTimeRange, effectiveOrigin);
const entries = buildEntries(jails); const entries = buildEntries(jails);
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT); const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);

View File

@@ -6,7 +6,6 @@
* database. The time-range selection controls how far back to look. * database. The time-range selection controls how far back to look.
*/ */
import { useState } from "react";
import { Text, makeStyles, tokens } from "@fluentui/react-components"; import { Text, makeStyles, tokens } from "@fluentui/react-components";
import { BanTable } from "../components/BanTable"; import { BanTable } from "../components/BanTable";
import { BanTrendChart } from "../components/BanTrendChart"; import { BanTrendChart } from "../components/BanTrendChart";
@@ -17,8 +16,7 @@ import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart"; import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
import { useCommonSectionStyles } from "../components/commonStyles"; import { useCommonSectionStyles } from "../components/commonStyles";
import { useDashboardCountryData } from "../hooks/useDashboardCountryData"; import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
import { getDataSource } from "../utils/queryUtils"; import { DashboardFilterProvider, useDashboardFilters } from "../providers/DashboardFilterProvider";
import type { BanOriginFilter, TimeRange } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -73,12 +71,9 @@ const useStyles = makeStyles({
* Displays the fail2ban server status, a time-range selector, and the * Displays the fail2ban server status, a time-range selector, and the
* ban list table. * ban list table.
*/ */
export function DashboardPage(): React.JSX.Element { function DashboardPageContent(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const [timeRange, setTimeRange] = useState<TimeRange>("24h"); const { timeRange, originFilter, source } = useDashboardFilters();
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const source = getDataSource(timeRange);
const { countries, countryNames, loading: countryLoading, error: countryError, reload: reloadCountry } = const { countries, countryNames, loading: countryLoading, error: countryError, reload: reloadCountry } =
useDashboardCountryData(timeRange, originFilter, source); useDashboardCountryData(timeRange, originFilter, source);
@@ -96,12 +91,7 @@ export function DashboardPage(): React.JSX.Element {
{/* Global filter bar */} {/* Global filter bar */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<div className={styles.filterRow}> <div className={styles.filterRow}>
<DashboardFilterBar <DashboardFilterBar />
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
originFilter={originFilter}
onOriginFilterChange={setOriginFilter}
/>
</div> </div>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
@@ -114,7 +104,7 @@ export function DashboardPage(): React.JSX.Element {
</Text> </Text>
</div> </div>
<div className={styles.tabContent}> <div className={styles.tabContent}>
<BanTrendChart timeRange={timeRange} origin={originFilter} source={source} /> <BanTrendChart />
</div> </div>
</div> </div>
@@ -165,10 +155,18 @@ export function DashboardPage(): React.JSX.Element {
{/* Ban table */} {/* Ban table */}
<div className={styles.tabContent}> <div className={styles.tabContent}>
<BanTable timeRange={timeRange} origin={originFilter} source={source} /> <BanTable />
</div> </div>
</div> </div>
</div> </div>
); );
} }
export function DashboardPage(): React.JSX.Element {
return (
<DashboardFilterProvider>
<DashboardPageContent />
</DashboardFilterProvider>
);
}

View File

@@ -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<DashboardFilterContextValue | null>(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<TimeRange>(DEFAULT_FILTERS.timeRange);
const [originFilter, setOriginFilter] = useState<BanOriginFilter>(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 <DashboardFilterContext.Provider value={value}>{children}</DashboardFilterContext.Provider>;
}
export function useDashboardFilters(): DashboardFilterContextValue {
const context = useContext(DashboardFilterContext);
return context ?? DEFAULT_FILTERS;
}

View File

@@ -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 <div data-testid="dashboard-filters">{`${timeRange}-${originFilter}`}</div>;
}
describe("DashboardFilterProvider", () => {
it("provides filter state to DashboardFilterBar and updates when the user clicks", async () => {
const user = userEvent.setup();
render(
<DashboardFilterProvider>
<DashboardFilterBar />
<DashboardFilterSummary />
</DashboardFilterProvider>,
);
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");
});
});