mirror of
https://github.com/prometheus/prometheus.git
synced 2025-08-05 13:47:10 +02:00
Merge pull request #16605 from prometheus/rules-page-filters
Add health & text filtering on the /rules page
This commit is contained in:
commit
6c930e8506
@ -37,7 +37,7 @@ type RecordingRule = {
|
||||
|
||||
export type Rule = AlertingRule | RecordingRule;
|
||||
|
||||
interface RuleGroup {
|
||||
export interface RuleGroup {
|
||||
name: string;
|
||||
file: string;
|
||||
interval: string;
|
||||
|
@ -217,12 +217,7 @@ export default function AlertsPage() {
|
||||
const renderedPageItems = useMemo(
|
||||
() =>
|
||||
currentPageGroups.map((g) => (
|
||||
<Card
|
||||
shadow="xs"
|
||||
withBorder
|
||||
p="md"
|
||||
key={`${g.file}-${g.name}`}
|
||||
>
|
||||
<Card shadow="xs" withBorder p="md" key={`${g.file}-${g.name}`}>
|
||||
<Group mb="sm" justify="space-between">
|
||||
<Group align="baseline">
|
||||
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
|
||||
@ -460,15 +455,13 @@ export default function AlertsPage() {
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
<Stack>
|
||||
<Pagination
|
||||
total={totalPageCount}
|
||||
value={effectiveActivePage}
|
||||
onChange={setActivePage}
|
||||
hideWithOnePage
|
||||
/>
|
||||
{renderedPageItems}
|
||||
</Stack>
|
||||
<Pagination
|
||||
total={totalPageCount}
|
||||
value={effectiveActivePage}
|
||||
onChange={setActivePage}
|
||||
hideWithOnePage
|
||||
/>
|
||||
{renderedPageItems}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -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<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) => {
|
||||
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<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(
|
||||
"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 (
|
||||
<Stack mt="xs">
|
||||
{data.data.groups.length === 0 && (
|
||||
<Alert title="No rule groups" icon={<IconInfoCircle />}>
|
||||
No rule groups configured.
|
||||
</Alert>
|
||||
)}
|
||||
<Pagination
|
||||
total={totalPageCount}
|
||||
value={effectiveActivePage}
|
||||
onChange={setActivePage}
|
||||
hideWithOnePage
|
||||
/>
|
||||
{data.data.groups
|
||||
.slice(
|
||||
(effectiveActivePage - 1) * ruleGroupsPerPage,
|
||||
effectiveActivePage * ruleGroupsPerPage
|
||||
)
|
||||
.map((g) => (
|
||||
<Card
|
||||
shadow="xs"
|
||||
withBorder
|
||||
p="md"
|
||||
mb="md"
|
||||
key={`${g.file}-${g.name}`}
|
||||
>
|
||||
<Group mb="sm" justify="space-between">
|
||||
<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>
|
||||
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) => (
|
||||
<Card shadow="xs" withBorder p="md" key={`${g.file}-${g.name}`}>
|
||||
<Group mb="sm" justify="space-between">
|
||||
<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>
|
||||
{g.rules.length === 0 && (
|
||||
<Alert title="No rules" icon={<IconInfoCircle />}>
|
||||
No rules in rule group.
|
||||
</Alert>
|
||||
)}
|
||||
<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>
|
||||
{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
|
||||
allItems={g.rules}
|
||||
child={({ items }) => (
|
||||
@ -248,8 +334,64 @@ export default function RulesPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user