Paginate rule groups, add infinite scroll to rules within groups
Some checks failed
CI / Go tests (push) Has been cancelled
CI / More Go tests (push) Has been cancelled
CI / Go tests with previous Go version (push) Has been cancelled
CI / UI tests (push) Has been cancelled
CI / Go tests on Windows (push) Has been cancelled
CI / Mixins tests (push) Has been cancelled
CI / Build Prometheus for common architectures (0) (push) Has been cancelled
CI / Build Prometheus for common architectures (1) (push) Has been cancelled
CI / Build Prometheus for common architectures (2) (push) Has been cancelled
CI / Build Prometheus for all architectures (0) (push) Has been cancelled
CI / Build Prometheus for all architectures (1) (push) Has been cancelled
CI / Build Prometheus for all architectures (10) (push) Has been cancelled
CI / Build Prometheus for all architectures (11) (push) Has been cancelled
CI / Build Prometheus for all architectures (2) (push) Has been cancelled
CI / Build Prometheus for all architectures (3) (push) Has been cancelled
CI / Build Prometheus for all architectures (4) (push) Has been cancelled
CI / Build Prometheus for all architectures (5) (push) Has been cancelled
CI / Build Prometheus for all architectures (6) (push) Has been cancelled
CI / Build Prometheus for all architectures (7) (push) Has been cancelled
CI / Build Prometheus for all architectures (8) (push) Has been cancelled
CI / Build Prometheus for all architectures (9) (push) Has been cancelled
CI / Check generated parser (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
CI / fuzzing (push) Has been cancelled
CI / codeql (push) Has been cancelled
CI / Report status of build Prometheus for all architectures (push) Has been cancelled
CI / Publish main branch artifacts (push) Has been cancelled
CI / Publish release artefacts (push) Has been cancelled
CI / Publish UI on npm Registry (push) Has been cancelled

This addresses extreme slowness when you have thousands of rules in
potentially hundreds of rule groups. It can still be a bit slow even with
pagination and infinite scroll for very large use cases, but it's much
better already than before.

Fixes https://github.com/prometheus/prometheus/issues/15551

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-12-13 12:50:05 +01:00
parent addaf419ef
commit 84e0f43a0c
5 changed files with 514 additions and 348 deletions

View file

@ -1,4 +1,12 @@
import { Popover, ActionIcon, Fieldset, Checkbox, Stack } from "@mantine/core";
import {
Popover,
ActionIcon,
Fieldset,
Checkbox,
Stack,
Group,
NumberInput,
} from "@mantine/core";
import { IconSettings } from "@tabler/icons-react";
import { FC } from "react";
import { useAppDispatch } from "../state/hooks";
@ -13,6 +21,8 @@ const SettingsMenu: FC = () => {
enableSyntaxHighlighting,
enableLinter,
showAnnotations,
ruleGroupsPerPage,
alertGroupsPerPage,
} = useSettings();
const dispatch = useAppDispatch();
@ -29,6 +39,7 @@ const SettingsMenu: FC = () => {
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Group align="flex-start">
<Stack>
<Fieldset p="md" legend="Global settings">
<Checkbox
@ -36,7 +47,9 @@ const SettingsMenu: FC = () => {
label="Use local time"
onChange={(event) =>
dispatch(
updateSettings({ useLocalTime: event.currentTarget.checked })
updateSettings({
useLocalTime: event.currentTarget.checked,
})
)
}
/>
@ -90,7 +103,9 @@ const SettingsMenu: FC = () => {
/>
</Stack>
</Fieldset>
</Stack>
<Stack>
<Fieldset p="md" legend="Alerts page settings">
<Checkbox
checked={showAnnotations}
@ -104,7 +119,46 @@ const SettingsMenu: FC = () => {
}
/>
</Fieldset>
<Fieldset p="md" legend="Alerts page settings">
<NumberInput
min={1}
allowDecimal={false}
label="Alert groups per page"
value={alertGroupsPerPage}
onChange={(value) => {
if (typeof value !== "number") {
return;
}
dispatch(
updateSettings({
alertGroupsPerPage: value,
})
);
}}
/>
</Fieldset>
<Fieldset p="md" legend="Rules page settings">
<NumberInput
min={1}
allowDecimal={false}
label="Rule groups per page"
value={ruleGroupsPerPage}
onChange={(value) => {
if (typeof value !== "number") {
return;
}
dispatch(
updateSettings({
ruleGroupsPerPage: value,
})
);
}}
/>
</Fieldset>
</Stack>
</Group>
</Popover.Dropdown>
</Popover>
);

View file

@ -11,6 +11,7 @@ import {
Alert,
TextInput,
Anchor,
Pagination,
} from "@mantine/core";
import { useSuspenseAPIQuery } from "../api/api";
import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules";
@ -18,7 +19,7 @@ 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, useMemo } from "react";
import { Fragment, useEffect, useMemo } from "react";
import { StateMultiSelect } from "../components/StateMultiSelect";
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
import { LabelBadges } from "../components/LabelBadges";
@ -26,6 +27,7 @@ import { useSettings } from "../state/settingsSlice";
import {
ArrayParam,
BooleanParam,
NumberParam,
StringParam,
useQueryParam,
withDefault,
@ -33,6 +35,7 @@ import {
import { useDebouncedValue } from "@mantine/hooks";
import { KVSearch } from "@nexucis/kvsearch";
import { inputIconStyle } from "../styles";
import CustomInfiniteScroll from "../components/CustomInfiniteScroll";
type AlertsPageData = {
// How many rules are in each state across all groups.
@ -132,6 +135,12 @@ const buildAlertsPageData = (
return pageData;
};
// 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 state filter is selected.
const emptyStateFilter: string[] = [];
export default function AlertsPage() {
// Fetch the alerting rules data.
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
@ -146,7 +155,7 @@ export default function AlertsPage() {
// Define URL query params.
const [stateFilter, setStateFilter] = useQueryParam(
"state",
withDefault(ArrayParam, [])
withDefault(ArrayParam, emptyStateFilter)
);
const [searchFilter, setSearchFilter] = useQueryParam(
"search",
@ -158,69 +167,55 @@ export default function AlertsPage() {
withDefault(BooleanParam, true)
);
const { alertGroupsPerPage } = useSettings();
const [activePage, setActivePage] = useQueryParam(
"page",
withDefault(NumberParam, 1)
);
// 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
const shownGroups = useMemo(
() =>
showEmptyGroups
? alertsPageData.groups
: alertsPageData.groups.filter((g) => g.rules.length > 0);
: alertsPageData.groups.filter((g) => g.rules.length > 0),
[alertsPageData.groups, showEmptyGroups]
);
return (
<Stack mt="xs">
<Group>
<StateMultiSelect
options={["inactive", "pending", "firing"]}
optionClass={(o) =>
o === "inactive"
? badgeClasses.healthOk
: o === "pending"
? badgeClasses.healthWarn
: badgeClasses.healthErr
// 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 / alertGroupsPerPage);
const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount));
useEffect(() => {
if (effectiveActivePage !== activePage) {
setActivePage(effectiveActivePage);
}
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)}
/>
<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>
{alertsPageData.groups.length === 0 ? (
<Alert title="No rules found" icon={<IconInfoCircle />}>
No rules found.
</Alert>
) : (
!showEmptyGroups &&
alertsPageData.groups.length !== shownGroups.length && (
<Alert
title="Hiding groups with no matching rules"
icon={<IconInfoCircle/>}
>
Hiding {alertsPageData.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>
)
)}
<Stack>
{shownGroups.map((g, i) => {
return (
}, [effectiveActivePage, activePage, setActivePage]);
const currentPageGroups = useMemo(
() =>
shownGroups.slice(
(effectiveActivePage - 1) * alertGroupsPerPage,
effectiveActivePage * alertGroupsPerPage
),
[shownGroups, effectiveActivePage, alertGroupsPerPage]
);
// 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, i) => (
<Card
shadow="xs"
withBorder
@ -229,11 +224,7 @@ export default function AlertsPage() {
>
<Group mb="md" mt="xs" ml="xs" justify="space-between">
<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)">
{g.name}
</Text>
<Text fz="sm" c="gray.6">
@ -282,8 +273,11 @@ export default function AlertsPage() {
</Anchor>
</Alert>
) : (
<CustomInfiniteScroll
allItems={g.rules}
child={({ items }) => (
<Accordion multiple variant="separated">
{g.rules.map((r, j) => {
{items.map((r, j) => {
return (
<Accordion.Item
styles={{
@ -405,9 +399,71 @@ export default function AlertsPage() {
})}
</Accordion>
)}
/>
)}
</Card>
)),
[currentPageGroups, showAnnotations, setShowEmptyGroups]
);
})}
return (
<Stack mt="xs">
<Group>
<StateMultiSelect
options={["inactive", "pending", "firing"]}
optionClass={(o) =>
o === "inactive"
? badgeClasses.healthOk
: o === "pending"
? badgeClasses.healthWarn
: badgeClasses.healthErr
}
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)}
/>
<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>
{alertsPageData.groups.length === 0 ? (
<Alert title="No rules found" icon={<IconInfoCircle />}>
No rules found.
</Alert>
) : (
!showEmptyGroups &&
alertsPageData.groups.length !== shownGroups.length && (
<Alert
title="Hiding groups with no matching rules"
icon={<IconInfoCircle />}
>
Hiding {alertsPageData.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>
)
)}
<Stack>
<Pagination
total={totalPageCount}
value={effectiveActivePage}
onChange={setActivePage}
hideWithOnePage
/>
{renderedPageItems}
</Stack>
</Stack>
);

View file

@ -4,6 +4,7 @@ import {
Badge,
Card,
Group,
Pagination,
rem,
Stack,
Text,
@ -29,6 +30,10 @@ import { 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 { useSettings } from "../state/settingsSlice";
import { useEffect } from "react";
import CustomInfiniteScroll from "../components/CustomInfiniteScroll";
const healthBadgeClass = (state: string) => {
switch (state) {
@ -45,6 +50,23 @@ const healthBadgeClass = (state: string) => {
export default function RulesPage() {
const { data } = useSuspenseAPIQuery<RulesResult>({ path: `/rules` });
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);
const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount));
useEffect(() => {
if (effectiveActivePage !== activePage) {
setActivePage(effectiveActivePage);
}
}, [effectiveActivePage, activePage, setActivePage]);
return (
<Stack mt="xs">
@ -53,7 +75,18 @@ export default function RulesPage() {
No rule groups configured.
</Alert>
)}
{data.data.groups.map((g, i) => (
<Pagination
total={totalPageCount}
value={effectiveActivePage}
onChange={setActivePage}
hideWithOnePage
/>
{data.data.groups
.slice(
(effectiveActivePage - 1) * ruleGroupsPerPage,
effectiveActivePage * ruleGroupsPerPage
)
.map((g, i) => (
<Card
shadow="xs"
withBorder
@ -108,8 +141,11 @@ export default function RulesPage() {
No rules in rule group.
</Alert>
)}
<CustomInfiniteScroll
allItems={g.rules}
child={({ items }) => (
<Accordion multiple variant="separated">
{g.rules.map((r, j) => (
{items.map((r, j) => (
<Accordion.Item
styles={{
item: {
@ -156,9 +192,14 @@ export default function RulesPage() {
variant="light"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRefresh style={badgeIconStyle} />}
leftSection={
<IconRefresh style={badgeIconStyle} />
}
>
{humanizeDurationRelative(r.lastEvaluation, now())}
{humanizeDurationRelative(
r.lastEvaluation,
now()
)}
</Badge>
</Tooltip>
@ -202,6 +243,8 @@ export default function RulesPage() {
</Accordion.Item>
))}
</Accordion>
)}
/>
</Card>
))}
</Stack>

View file

@ -63,6 +63,7 @@ startAppListening({
case "enableSyntaxHighlighting":
case "enableLinter":
case "showAnnotations":
case "ruleGroupsPerPage":
return persistToLocalStorage(`settings.${key}`, value);
}
});

View file

@ -14,6 +14,8 @@ interface Settings {
enableSyntaxHighlighting: boolean;
enableLinter: boolean;
showAnnotations: boolean;
ruleGroupsPerPage: number;
alertGroupsPerPage: number;
}
// Declared/defined in public/index.html, value replaced by Prometheus when serving bundle.
@ -29,6 +31,8 @@ export const localStorageKeyEnableSyntaxHighlighting =
"settings.enableSyntaxHighlighting";
export const localStorageKeyEnableLinter = "settings.enableLinter";
export const localStorageKeyShowAnnotations = "settings.showAnnotations";
export const localStorageKeyRuleGroupsPerPage = "settings.ruleGroupsPerPage";
export const localStorageKeyAlertGroupsPerPage = "settings.alertGroupsPerPage";
// This dynamically/generically determines the pathPrefix by stripping the first known
// endpoint suffix from the window location path. It works out of the box for both direct
@ -95,6 +99,14 @@ export const initialState: Settings = {
localStorageKeyShowAnnotations,
true
),
ruleGroupsPerPage: initializeFromLocalStorage<number>(
localStorageKeyRuleGroupsPerPage,
10
),
alertGroupsPerPage: initializeFromLocalStorage<number>(
localStorageKeyAlertGroupsPerPage,
10
),
};
export const settingsSlice = createSlice({