diff --git a/web/ui/mantine-ui/src/api/responseTypes/rules.ts b/web/ui/mantine-ui/src/api/responseTypes/rules.ts index 13535315c7..ff04ad2cc9 100644 --- a/web/ui/mantine-ui/src/api/responseTypes/rules.ts +++ b/web/ui/mantine-ui/src/api/responseTypes/rules.ts @@ -37,7 +37,7 @@ type RecordingRule = { export type Rule = AlertingRule | RecordingRule; -interface RuleGroup { +export interface RuleGroup { name: string; file: string; interval: string; diff --git a/web/ui/mantine-ui/src/pages/AlertsPage.tsx b/web/ui/mantine-ui/src/pages/AlertsPage.tsx index a905850628..9605a3986b 100644 --- a/web/ui/mantine-ui/src/pages/AlertsPage.tsx +++ b/web/ui/mantine-ui/src/pages/AlertsPage.tsx @@ -217,12 +217,7 @@ export default function AlertsPage() { const renderedPageItems = useMemo( () => currentPageGroups.map((g) => ( - + @@ -460,15 +455,13 @@ export default function AlertsPage() { ) )} - - - {renderedPageItems} - + + {renderedPageItems} ); } diff --git a/web/ui/mantine-ui/src/pages/RulesPage.tsx b/web/ui/mantine-ui/src/pages/RulesPage.tsx index 054bea0939..29c8b5a89e 100644 --- a/web/ui/mantine-ui/src/pages/RulesPage.tsx +++ b/web/ui/mantine-ui/src/pages/RulesPage.tsx @@ -1,6 +1,7 @@ import { Accordion, Alert, + Anchor, Badge, Card, Group, @@ -8,9 +9,9 @@ import { rem, Stack, Text, + TextInput, Tooltip, } from "@mantine/core"; -// import { useQuery } from "react-query"; import { humanizeDurationRelative, humanizeDuration, @@ -23,17 +24,55 @@ import { IconInfoCircle, IconRefresh, IconRepeat, + IconSearch, IconTimeline, } from "@tabler/icons-react"; import { useSuspenseAPIQuery } from "../api/api"; -import { RulesResult } from "../api/responseTypes/rules"; +import { Rule, RuleGroup, RulesResult } from "../api/responseTypes/rules"; import badgeClasses from "../Badge.module.css"; import RuleDefinition from "../components/RuleDefinition"; -import { badgeIconStyle } from "../styles"; -import { NumberParam, useQueryParam, withDefault } from "use-query-params"; +import { badgeIconStyle, inputIconStyle } from "../styles"; +import { + ArrayParam, + NumberParam, + StringParam, + useQueryParam, + withDefault, +} from "use-query-params"; import { useSettings } from "../state/settingsSlice"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import CustomInfiniteScroll from "../components/CustomInfiniteScroll"; +import { useDebouncedValue, useLocalStorage } from "@mantine/hooks"; +import { KVSearch } from "@nexucis/kvsearch"; +import { StateMultiSelect } from "../components/StateMultiSelect"; + +const kvSearch = new KVSearch({ + shouldSort: true, + indexedKeys: ["name", "labels", ["labels", /.*/]], +}); + +type RulesPageData = { + groups: (RuleGroup & { prefilterRulesCount: number })[]; +}; + +const buildRulesPageData = ( + data: RulesResult, + search: string, + healthFilter: (string | null)[] +): RulesPageData => { + const groups = data.groups.map((group) => ({ + ...group, + prefilterRulesCount: group.rules.length, + rules: (search === "" + ? group.rules + : kvSearch.filter(search, group.rules).map((value) => value.original) + ).filter( + (r) => healthFilter.length === 0 || healthFilter.includes(r.health) + ), + })); + + return { groups }; +}; const healthBadgeClass = (state: string) => { switch (state) { @@ -48,18 +87,53 @@ const healthBadgeClass = (state: string) => { } }; +// Should be defined as a constant here instead of inline as a value +// to avoid unnecessary re-renders. Otherwise the empty array has +// a different reference on each render and causes subsequent memoized +// computations to re-run as long as no health filter is selected. +const emptyHealthFilter: string[] = []; + export default function RulesPage() { const { data } = useSuspenseAPIQuery({ path: `/rules` }); - const { ruleGroupsPerPage } = useSettings(); + // Define URL query params. + const [healthFilter, setHealthFilter] = useQueryParam( + "health", + withDefault(ArrayParam, emptyHealthFilter) + ); + const [searchFilter, setSearchFilter] = useQueryParam( + "search", + withDefault(StringParam, "") + ); + const [debouncedSearch] = useDebouncedValue(searchFilter.trim(), 250); + const [showEmptyGroups, setShowEmptyGroups] = useLocalStorage({ + key: "alertsPage.showEmptyGroups", + defaultValue: false, + }); + + const { ruleGroupsPerPage } = useSettings(); const [activePage, setActivePage] = useQueryParam( "page", withDefault(NumberParam, 1) ); - // If we were e.g. on page 10 and the number of total pages decreases to 5 (due - // changing the max number of items per page), go to the largest possible page. - const totalPageCount = Math.ceil(data.data.groups.length / ruleGroupsPerPage); + // Update the page data whenever the fetched data or filters change. + const rulesPageData = useMemo( + () => buildRulesPageData(data.data, debouncedSearch, healthFilter), + [data, healthFilter, debouncedSearch] + ); + + const shownGroups = useMemo( + () => + showEmptyGroups + ? rulesPageData.groups + : rulesPageData.groups.filter((g) => g.rules.length > 0), + [rulesPageData.groups, showEmptyGroups] + ); + + // If we were e.g. on page 10 and the number of total pages decreases to 5 (due to filtering + // or changing the max number of items per page), go to the largest possible page. + const totalPageCount = Math.ceil(shownGroups.length / ruleGroupsPerPage); const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount)); useEffect(() => { @@ -68,79 +142,91 @@ export default function RulesPage() { } }, [effectiveActivePage, activePage, setActivePage]); - return ( - - {data.data.groups.length === 0 && ( - }> - No rule groups configured. - - )} - - {data.data.groups - .slice( - (effectiveActivePage - 1) * ruleGroupsPerPage, - effectiveActivePage * ruleGroupsPerPage - ) - .map((g) => ( - - - - - {g.name} - - - {g.file} - - - - - } - > - last run {humanizeDurationRelative(g.lastEvaluation, now())} - - - - } - > - took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)} - - - - } - > - every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "} - - - + const currentPageGroups = useMemo( + () => + shownGroups.slice( + (effectiveActivePage - 1) * ruleGroupsPerPage, + effectiveActivePage * ruleGroupsPerPage + ), + [shownGroups, effectiveActivePage, ruleGroupsPerPage] + ); + + // We memoize the actual rendering of the page items to avoid re-rendering + // them on every state change. This is especially important when the user + // types into the search box, as the search filter changes on every keystroke, + // even before debouncing takes place (extracting the filters and results list + // into separate components would be an alternative to this, but it's kinda + // convenient to have in the same file IMO). + const renderedPageItems = useMemo( + () => + currentPageGroups.map((g) => ( + + + + + {g.name} + + + {g.file} + - {g.rules.length === 0 && ( - }> - No rules in rule group. - - )} + + + } + > + last run {humanizeDurationRelative(g.lastEvaluation, now())} + + + + } + > + took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)} + + + + } + > + every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "} + + + + + {g.prefilterRulesCount === 0 ? ( + }> + No rules in this group. + setShowEmptyGroups(false)} + > + Hide empty groups + + + ) : g.rules.length === 0 ? ( + }> + No rules in this group match your filter criteria (omitted{" "} + {g.prefilterRulesCount} filtered rules). + setShowEmptyGroups(false)} + > + Hide empty groups + + + ) : ( ( @@ -248,8 +334,64 @@ export default function RulesPage() { )} /> - - ))} + )} + + )), + [currentPageGroups, setShowEmptyGroups] + ); + + return ( + + + + o === "ok" + ? badgeClasses.healthOk + : o === "unknown" + ? badgeClasses.healthWarn + : badgeClasses.healthErr + } + placeholder="Filter by rule health" + values={(healthFilter?.filter((v) => v !== null) as string[]) || []} + onChange={(values) => setHealthFilter(values)} + /> + } + placeholder="Filter by rule name or labels" + value={searchFilter || ""} + onChange={(event) => + setSearchFilter(event.currentTarget.value || null) + } + > + + {rulesPageData.groups.length === 0 ? ( + }> + No rules found. + + ) : ( + !showEmptyGroups && + rulesPageData.groups.length !== shownGroups.length && ( + } + > + Hiding {rulesPageData.groups.length - shownGroups.length} empty + groups due to filters or no rules. + setShowEmptyGroups(true)}> + Show empty groups + + + ) + )} + + {renderedPageItems} ); }