Merge pull request #16605 from prometheus/rules-page-filters

Add health & text filtering on the /rules page
This commit is contained in:
Julius Volz 2025-05-19 23:22:25 +02:00 committed by GitHub
commit 6c930e8506
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 234 additions and 99 deletions

View File

@ -37,7 +37,7 @@ type RecordingRule = {
export type Rule = AlertingRule | RecordingRule; export type Rule = AlertingRule | RecordingRule;
interface RuleGroup { export interface RuleGroup {
name: string; name: string;
file: string; file: string;
interval: string; interval: string;

View File

@ -217,12 +217,7 @@ export default function AlertsPage() {
const renderedPageItems = useMemo( const renderedPageItems = useMemo(
() => () =>
currentPageGroups.map((g) => ( currentPageGroups.map((g) => (
<Card <Card shadow="xs" withBorder p="md" key={`${g.file}-${g.name}`}>
shadow="xs"
withBorder
p="md"
key={`${g.file}-${g.name}`}
>
<Group mb="sm" justify="space-between"> <Group mb="sm" justify="space-between">
<Group align="baseline"> <Group align="baseline">
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)"> <Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
@ -460,15 +455,13 @@ export default function AlertsPage() {
</Alert> </Alert>
) )
)} )}
<Stack> <Pagination
<Pagination total={totalPageCount}
total={totalPageCount} value={effectiveActivePage}
value={effectiveActivePage} onChange={setActivePage}
onChange={setActivePage} hideWithOnePage
hideWithOnePage />
/> {renderedPageItems}
{renderedPageItems}
</Stack>
</Stack> </Stack>
); );
} }

View File

@ -1,6 +1,7 @@
import { import {
Accordion, Accordion,
Alert, Alert,
Anchor,
Badge, Badge,
Card, Card,
Group, Group,
@ -8,9 +9,9 @@ import {
rem, rem,
Stack, Stack,
Text, Text,
TextInput,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
// import { useQuery } from "react-query";
import { import {
humanizeDurationRelative, humanizeDurationRelative,
humanizeDuration, humanizeDuration,
@ -23,17 +24,55 @@ import {
IconInfoCircle, IconInfoCircle,
IconRefresh, IconRefresh,
IconRepeat, IconRepeat,
IconSearch,
IconTimeline, IconTimeline,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useSuspenseAPIQuery } from "../api/api"; 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 badgeClasses from "../Badge.module.css";
import RuleDefinition from "../components/RuleDefinition"; import RuleDefinition from "../components/RuleDefinition";
import { badgeIconStyle } from "../styles"; import { badgeIconStyle, inputIconStyle } from "../styles";
import { NumberParam, useQueryParam, withDefault } from "use-query-params"; import {
ArrayParam,
NumberParam,
StringParam,
useQueryParam,
withDefault,
} from "use-query-params";
import { useSettings } from "../state/settingsSlice"; import { useSettings } from "../state/settingsSlice";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import CustomInfiniteScroll from "../components/CustomInfiniteScroll"; 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<Rule>({
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) => { const healthBadgeClass = (state: string) => {
switch (state) { 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() { export default function RulesPage() {
const { data } = useSuspenseAPIQuery<RulesResult>({ path: `/rules` }); const { data } = useSuspenseAPIQuery<RulesResult>({ 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<string>(searchFilter.trim(), 250);
const [showEmptyGroups, setShowEmptyGroups] = useLocalStorage<boolean>({
key: "alertsPage.showEmptyGroups",
defaultValue: false,
});
const { ruleGroupsPerPage } = useSettings();
const [activePage, setActivePage] = useQueryParam( const [activePage, setActivePage] = useQueryParam(
"page", "page",
withDefault(NumberParam, 1) withDefault(NumberParam, 1)
); );
// If we were e.g. on page 10 and the number of total pages decreases to 5 (due // Update the page data whenever the fetched data or filters change.
// changing the max number of items per page), go to the largest possible page. const rulesPageData = useMemo(
const totalPageCount = Math.ceil(data.data.groups.length / ruleGroupsPerPage); () => 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)); const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount));
useEffect(() => { useEffect(() => {
@ -68,79 +142,91 @@ export default function RulesPage() {
} }
}, [effectiveActivePage, activePage, setActivePage]); }, [effectiveActivePage, activePage, setActivePage]);
return ( const currentPageGroups = useMemo(
<Stack mt="xs"> () =>
{data.data.groups.length === 0 && ( shownGroups.slice(
<Alert title="No rule groups" icon={<IconInfoCircle />}> (effectiveActivePage - 1) * ruleGroupsPerPage,
No rule groups configured. effectiveActivePage * ruleGroupsPerPage
</Alert> ),
)} [shownGroups, effectiveActivePage, ruleGroupsPerPage]
<Pagination );
total={totalPageCount}
value={effectiveActivePage} // We memoize the actual rendering of the page items to avoid re-rendering
onChange={setActivePage} // them on every state change. This is especially important when the user
hideWithOnePage // types into the search box, as the search filter changes on every keystroke,
/> // even before debouncing takes place (extracting the filters and results list
{data.data.groups // into separate components would be an alternative to this, but it's kinda
.slice( // convenient to have in the same file IMO).
(effectiveActivePage - 1) * ruleGroupsPerPage, const renderedPageItems = useMemo(
effectiveActivePage * ruleGroupsPerPage () =>
) currentPageGroups.map((g) => (
.map((g) => ( <Card shadow="xs" withBorder p="md" key={`${g.file}-${g.name}`}>
<Card <Group mb="sm" justify="space-between">
shadow="xs" <Group align="baseline">
withBorder <Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
p="md" {g.name}
mb="md" </Text>
key={`${g.file}-${g.name}`} <Text fz="sm" c="gray.6">
> {g.file}
<Group mb="sm" justify="space-between"> </Text>
<Group align="baseline">
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
{g.name}
</Text>
<Text fz="sm" c="gray.6">
{g.file}
</Text>
</Group>
<Group>
<Tooltip label="Last group evaluation" withArrow>
<Badge
variant="light"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRefresh style={badgeIconStyle} />}
>
last run {humanizeDurationRelative(g.lastEvaluation, now())}
</Badge>
</Tooltip>
<Tooltip label="Duration of last group evaluation" withArrow>
<Badge
variant="light"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconHourglass style={badgeIconStyle} />}
>
took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)}
</Badge>
</Tooltip>
<Tooltip label="Group evaluation interval" withArrow>
<Badge
variant="transparent"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRepeat style={badgeIconStyle} />}
>
every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "}
</Badge>
</Tooltip>
</Group>
</Group> </Group>
{g.rules.length === 0 && ( <Group>
<Alert title="No rules" icon={<IconInfoCircle />}> <Tooltip label="Last group evaluation" withArrow>
No rules in rule group. <Badge
</Alert> variant="light"
)} className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRefresh style={badgeIconStyle} />}
>
last run {humanizeDurationRelative(g.lastEvaluation, now())}
</Badge>
</Tooltip>
<Tooltip label="Duration of last group evaluation" withArrow>
<Badge
variant="light"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconHourglass style={badgeIconStyle} />}
>
took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)}
</Badge>
</Tooltip>
<Tooltip label="Group evaluation interval" withArrow>
<Badge
variant="transparent"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRepeat style={badgeIconStyle} />}
>
every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "}
</Badge>
</Tooltip>
</Group>
</Group>
{g.prefilterRulesCount === 0 ? (
<Alert title="No rules" icon={<IconInfoCircle />}>
No rules in this group.
<Anchor
ml="md"
fz="1em"
onClick={() => setShowEmptyGroups(false)}
>
Hide empty groups
</Anchor>
</Alert>
) : g.rules.length === 0 ? (
<Alert title="No matching rules" icon={<IconInfoCircle />}>
No rules in this group match your filter criteria (omitted{" "}
{g.prefilterRulesCount} filtered rules).
<Anchor
ml="md"
fz="1em"
onClick={() => setShowEmptyGroups(false)}
>
Hide empty groups
</Anchor>
</Alert>
) : (
<CustomInfiniteScroll <CustomInfiniteScroll
allItems={g.rules} allItems={g.rules}
child={({ items }) => ( child={({ items }) => (
@ -248,8 +334,64 @@ export default function RulesPage() {
</Accordion> </Accordion>
)} )}
/> />
</Card> )}
))} </Card>
)),
[currentPageGroups, setShowEmptyGroups]
);
return (
<Stack mt="xs">
<Group>
<StateMultiSelect
options={["ok", "unknown", "err"]}
optionClass={(o) =>
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)}
/>
<TextInput
flex={1}
leftSection={<IconSearch style={inputIconStyle} />}
placeholder="Filter by rule name or labels"
value={searchFilter || ""}
onChange={(event) =>
setSearchFilter(event.currentTarget.value || null)
}
></TextInput>
</Group>
{rulesPageData.groups.length === 0 ? (
<Alert title="No rules found" icon={<IconInfoCircle />}>
No rules found.
</Alert>
) : (
!showEmptyGroups &&
rulesPageData.groups.length !== shownGroups.length && (
<Alert
title="Hiding groups with no matching rules"
icon={<IconInfoCircle />}
>
Hiding {rulesPageData.groups.length - shownGroups.length} empty
groups due to filters or no rules.
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyGroups(true)}>
Show empty groups
</Anchor>
</Alert>
)
)}
<Pagination
total={totalPageCount}
value={effectiveActivePage}
onChange={setActivePage}
hideWithOnePage
/>
{renderedPageItems}
</Stack> </Stack>
); );
} }