From d6e5e39bf7ee8b1e61293f02a6dd9f59895c6f3c Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Wed, 28 Aug 2024 14:21:52 +0200 Subject: [PATCH] Add filtering to alerts page Signed-off-by: Julius Volz --- .../mantine-ui/src/api/responseTypes/rules.ts | 4 +- .../src/components/StateMultiSelect.tsx | 11 +- web/ui/mantine-ui/src/pages/AlertsPage.tsx | 474 ++++++++++++------ .../mantine-ui/src/state/alertsPageSlice.ts | 32 -- web/ui/mantine-ui/src/state/store.ts | 2 - 5 files changed, 335 insertions(+), 188 deletions(-) delete mode 100644 web/ui/mantine-ui/src/state/alertsPageSlice.ts diff --git a/web/ui/mantine-ui/src/api/responseTypes/rules.ts b/web/ui/mantine-ui/src/api/responseTypes/rules.ts index 4cc7e30f5..13535315c 100644 --- a/web/ui/mantine-ui/src/api/responseTypes/rules.ts +++ b/web/ui/mantine-ui/src/api/responseTypes/rules.ts @@ -18,7 +18,7 @@ type CommonRuleFields = { lastEvaluation: string; }; -type AlertingRule = { +export type AlertingRule = { type: "alerting"; // For alerting rules, the 'labels' field is always present, even when there are no labels. labels: Record; @@ -46,7 +46,7 @@ interface RuleGroup { lastEvaluation: string; } -type AlertingRuleGroup = Omit & { +export type AlertingRuleGroup = Omit & { rules: AlertingRule[]; }; diff --git a/web/ui/mantine-ui/src/components/StateMultiSelect.tsx b/web/ui/mantine-ui/src/components/StateMultiSelect.tsx index 7acd387f0..b986d7aa5 100644 --- a/web/ui/mantine-ui/src/components/StateMultiSelect.tsx +++ b/web/ui/mantine-ui/src/components/StateMultiSelect.tsx @@ -33,6 +33,7 @@ export function StatePill({ value, onRemove, ...others }: StatePillProps) { interface StateMultiSelectProps { options: string[]; optionClass: (option: string) => string; + optionCount?: (option: string) => number; placeholder: string; values: string[]; onChange: (values: string[]) => void; @@ -41,6 +42,7 @@ interface StateMultiSelectProps { export const StateMultiSelect: FC = ({ options, optionClass, + optionCount, placeholder, values, onChange, @@ -60,7 +62,7 @@ export const StateMultiSelect: FC = ({ const renderedValues = values.map((item) => ( handleValueRemove(item)} key={item} @@ -123,7 +125,12 @@ export const StateMultiSelect: FC = ({ {values.includes(value) ? ( ) : null} - + ); diff --git a/web/ui/mantine-ui/src/pages/AlertsPage.tsx b/web/ui/mantine-ui/src/pages/AlertsPage.tsx index a963fcd93..39ce1a494 100644 --- a/web/ui/mantine-ui/src/pages/AlertsPage.tsx +++ b/web/ui/mantine-ui/src/pages/AlertsPage.tsx @@ -8,24 +8,133 @@ import { Tooltip, Box, Stack, - Input, Alert, + TextInput, + Anchor, } from "@mantine/core"; import { useSuspenseAPIQuery } from "../api/api"; -import { AlertingRulesResult } from "../api/responseTypes/rules"; +import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules"; import badgeClasses from "../Badge.module.css"; import panelClasses from "../Panel.module.css"; import RuleDefinition from "../components/RuleDefinition"; import { humanizeDurationRelative, now } from "../lib/formatTime"; -import { Fragment } from "react"; +import { Fragment, useMemo } from "react"; import { StateMultiSelect } from "../components/StateMultiSelect"; -import { useAppDispatch, useAppSelector } from "../state/hooks"; import { IconInfoCircle, IconSearch } from "@tabler/icons-react"; import { LabelBadges } from "../components/LabelBadges"; -import { updateAlertFilters } from "../state/alertsPageSlice"; import { useSettings } from "../state/settingsSlice"; +import { + ArrayParam, + BooleanParam, + StringParam, + useQueryParam, + withDefault, +} from "use-query-params"; +import { useDebouncedValue } from "@mantine/hooks"; +import { KVSearch } from "@nexucis/kvsearch"; + +type AlertsPageData = { + // How many rules are in each state across all groups. + globalCounts: { + inactive: number; + pending: number; + firing: number; + }; + groups: { + name: string; + file: string; + // How many rules are in each state for this group. + counts: { + total: number; + inactive: number; + pending: number; + firing: number; + }; + rules: { + rule: AlertingRule; + // How many alerts are in each state for this rule. + counts: { + firing: number; + pending: number; + }; + }[]; + }[]; +}; + +const kvSearch = new KVSearch({ + shouldSort: true, + indexedKeys: ["name", "labels", ["labels", /.*/]], +}); + +const buildAlertsPageData = ( + data: AlertingRulesResult, + debouncedSearch: string, + stateFilter: (string | null)[] +) => { + const pageData: AlertsPageData = { + globalCounts: { + inactive: 0, + pending: 0, + firing: 0, + }, + groups: [], + }; + + for (const group of data.groups) { + const groupCounts = { + total: 0, + inactive: 0, + pending: 0, + firing: 0, + }; + + for (const r of group.rules) { + groupCounts.total++; + switch (r.state) { + case "inactive": + pageData.globalCounts.inactive++; + groupCounts.inactive++; + break; + case "firing": + pageData.globalCounts.firing++; + groupCounts.firing++; + break; + case "pending": + pageData.globalCounts.pending++; + groupCounts.pending++; + break; + default: + throw new Error(`Unknown rule state: ${r.state}`); + } + } + + const filteredRules: AlertingRule[] = ( + debouncedSearch === "" + ? group.rules + : kvSearch + .filter(debouncedSearch, group.rules) + .map((value) => value.original) + ).filter((r) => stateFilter.length === 0 || stateFilter.includes(r.state)); + + pageData.groups.push({ + name: group.name, + file: group.file, + counts: groupCounts, + rules: filteredRules.map((r) => ({ + rule: r, + counts: { + firing: r.alerts.filter((a) => a.state === "firing").length, + pending: r.alerts.filter((a) => a.state === "pending").length, + }, + })), + }); + } + + return pageData; +}; export default function AlertsPage() { + // Fetch the alerting rules data. const { data } = useSuspenseAPIQuery({ path: `/rules`, params: { @@ -33,23 +142,36 @@ export default function AlertsPage() { }, }); - const dispatch = useAppDispatch(); const { showAnnotations } = useSettings(); - const filters = useAppSelector((state) => state.alertsPage.filters); - const ruleStatsCount = { - inactive: 0, - pending: 0, - firing: 0, - }; - - data.data.groups.forEach((el) => - el.rules.forEach((r) => ruleStatsCount[r.state]++) + // Define URL query params. + const [stateFilter, setStateFilter] = useQueryParam( + "state", + withDefault(ArrayParam, []) + ); + const [searchFilter, setSearchFilter] = useQueryParam( + "search", + withDefault(StringParam, "") + ); + const [debouncedSearch] = useDebouncedValue(searchFilter.trim(), 250); + const [showEmptyGroups, setShowEmptyGroups] = useQueryParam( + "showEmptyGroups", + withDefault(BooleanParam, true) ); + // Update the page data whenever the fetched data or filters change. + const alertsPageData: AlertsPageData = useMemo( + () => buildAlertsPageData(data.data, debouncedSearch, stateFilter), + [data, stateFilter, debouncedSearch] + ); + + const shownGroups = showEmptyGroups + ? alertsPageData.groups + : alertsPageData.groups.filter((g) => g.rules.length > 0); + return ( - <> - + + @@ -59,21 +181,46 @@ export default function AlertsPage() { ? badgeClasses.healthWarn : badgeClasses.healthErr } - placeholder="Filter by alert state" - values={filters.state} - onChange={(values) => dispatch(updateAlertFilters({ state: values }))} + optionCount={(o) => + alertsPageData.globalCounts[ + o as keyof typeof alertsPageData.globalCounts + ] + } + placeholder="Filter by rule state" + values={(stateFilter?.filter((v) => v !== null) as string[]) || []} + onChange={(values) => setStateFilter(values)} /> - } - placeholder="Filter by alert name or labels" - > + placeholder="Filter by rule name or labels" + value={searchFilter || ""} + onChange={(event) => + setSearchFilter(event.currentTarget.value || null) + } + > + {alertsPageData.groups.length === 0 ? ( + }> + No rules found. + + ) : ( + !showEmptyGroups && + alertsPageData.groups.length !== shownGroups.length && ( + } + > + Hiding {alertsPageData.groups.length - shownGroups.length} empty + groups due to filters or no rules. + setShowEmptyGroups(true)}> + Show empty groups + + + ) + )} - {data.data.groups.map((g, i) => { - const filteredRules = g.rules.filter( - (r) => filters.state.length === 0 || filters.state.includes(r.state) - ); + {shownGroups.map((g, i) => { return ( + + {g.counts.firing > 0 && ( + + firing ({g.counts.firing}) + + )} + {g.counts.pending > 0 && ( + + pending ({g.counts.pending}) + + )} + {g.counts.inactive > 0 && ( + + inactive ({g.counts.inactive}) + + )} + - {filteredRules.length === 0 && ( - } - > - No rules found that match your filter criteria. + {g.counts.total === 0 ? ( + }> + No rules in this group. + setShowEmptyGroups(false)} + > + Hide empty groups + - )} - - {filteredRules.map((r, j) => { - const numFiring = r.alerts.filter( - (a) => a.state === "firing" - ).length; - const numPending = r.alerts.filter( - (a) => a.state === "pending" - ).length; - - return ( - 0 - ? panelClasses.panelHealthErr - : numPending > 0 - ? panelClasses.panelHealthWarn - : panelClasses.panelHealthOk - } - > - - - {r.name} - - {numFiring > 0 && ( - - firing ({numFiring}) - - )} - {numPending > 0 && ( - - pending ({numPending}) - - )} + ) : g.rules.length === 0 ? ( + }> + No rules in this group match your filter criteria (omitted{" "} + {g.counts.total} filtered rules). + setShowEmptyGroups(false)} + > + Hide empty groups + + + ) : ( + + {g.rules.map((r, j) => { + return ( + 0 + ? panelClasses.panelHealthErr + : r.counts.pending > 0 + ? panelClasses.panelHealthWarn + : panelClasses.panelHealthOk + } + > + + + {r.rule.name} + + {r.counts.firing > 0 && ( + + firing ({r.counts.firing}) + + )} + {r.counts.pending > 0 && ( + + pending ({r.counts.pending}) + + )} + - - - - - {r.alerts.length > 0 && ( - - - - Alert labels - State - Active Since - Value - - - - {r.type === "alerting" && - r.alerts.map((a, k) => ( - - - - - - - - {a.state} - - - - - - {humanizeDurationRelative( - a.activeAt, - now(), - "" - )} - - - - {a.value} - - {showAnnotations && ( + + + + {r.rule.alerts.length > 0 && ( +
+ + + Alert labels + State + Active Since + Value + + + + {r.rule.type === "alerting" && + r.rule.alerts.map((a, k) => ( + - -
- - {Object.entries( - a.annotations - ).map(([k, v]) => ( - - {k} - {v} - - ))} - -
+ + + + + {a.state} + + + + + + {humanizeDurationRelative( + a.activeAt, + now(), + "" + )} + + + + {a.value} - )} - - ))} - - - )} -
-
- ); - })} -
+ {showAnnotations && ( + + + + + {Object.entries( + a.annotations + ).map(([k, v]) => ( + + {k} + {v} + + ))} + +
+
+
+ )} + + ))} + + + )} + + + ); + })} + + )} ); })} - + ); } diff --git a/web/ui/mantine-ui/src/state/alertsPageSlice.ts b/web/ui/mantine-ui/src/state/alertsPageSlice.ts deleted file mode 100644 index 53e1bc61e..000000000 --- a/web/ui/mantine-ui/src/state/alertsPageSlice.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { PayloadAction, createSlice } from "@reduxjs/toolkit"; - -interface AlertFilters { - state: string[]; -} - -interface AlertsPage { - filters: AlertFilters; -} - -const initialState: AlertsPage = { - filters: { - state: [], - }, -}; - -export const alertsPageSlice = createSlice({ - name: "alertsPage", - initialState, - reducers: { - updateAlertFilters: ( - state, - { payload }: PayloadAction> - ) => { - Object.assign(state.filters, payload); - }, - }, -}); - -export const { updateAlertFilters } = alertsPageSlice.actions; - -export default alertsPageSlice.reducer; diff --git a/web/ui/mantine-ui/src/state/store.ts b/web/ui/mantine-ui/src/state/store.ts index aeaf8544f..f9a073c9b 100644 --- a/web/ui/mantine-ui/src/state/store.ts +++ b/web/ui/mantine-ui/src/state/store.ts @@ -2,7 +2,6 @@ import { configureStore } from "@reduxjs/toolkit"; import queryPageSlice from "./queryPageSlice"; import settingsSlice from "./settingsSlice"; import targetsPageSlice from "./targetsPageSlice"; -import alertsPageSlice from "./alertsPageSlice"; import { localStorageMiddleware } from "./localStorageMiddleware"; const store = configureStore({ @@ -10,7 +9,6 @@ const store = configureStore({ settings: settingsSlice, queryPage: queryPageSlice, targetsPage: targetsPageSlice, - alertsPage: alertsPageSlice, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(localStorageMiddleware.middleware),