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 { IconSettings } from "@tabler/icons-react";
import { FC } from "react"; import { FC } from "react";
import { useAppDispatch } from "../state/hooks"; import { useAppDispatch } from "../state/hooks";
@ -13,6 +21,8 @@ const SettingsMenu: FC = () => {
enableSyntaxHighlighting, enableSyntaxHighlighting,
enableLinter, enableLinter,
showAnnotations, showAnnotations,
ruleGroupsPerPage,
alertGroupsPerPage,
} = useSettings(); } = useSettings();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -29,82 +39,126 @@ const SettingsMenu: FC = () => {
</ActionIcon> </ActionIcon>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<Stack> <Group align="flex-start">
<Fieldset p="md" legend="Global settings"> <Stack>
<Checkbox <Fieldset p="md" legend="Global settings">
checked={useLocalTime} <Checkbox
label="Use local time" checked={useLocalTime}
onChange={(event) => label="Use local time"
dispatch( onChange={(event) =>
updateSettings({ useLocalTime: event.currentTarget.checked }) dispatch(
) updateSettings({
} useLocalTime: event.currentTarget.checked,
/> })
</Fieldset> )
}
/>
</Fieldset>
<Fieldset p="md" legend="Query page settings"> <Fieldset p="md" legend="Query page settings">
<Stack> <Stack>
<Checkbox <Checkbox
checked={enableQueryHistory} checked={enableQueryHistory}
label="Enable query history" label="Enable query history"
onChange={(event) => onChange={(event) =>
dispatch( dispatch(
updateSettings({ updateSettings({
enableQueryHistory: event.currentTarget.checked, enableQueryHistory: event.currentTarget.checked,
}) })
) )
} }
/> />
<Checkbox <Checkbox
checked={enableAutocomplete} checked={enableAutocomplete}
label="Enable autocomplete" label="Enable autocomplete"
onChange={(event) => onChange={(event) =>
dispatch( dispatch(
updateSettings({ updateSettings({
enableAutocomplete: event.currentTarget.checked, enableAutocomplete: event.currentTarget.checked,
}) })
) )
} }
/> />
<Checkbox <Checkbox
checked={enableSyntaxHighlighting} checked={enableSyntaxHighlighting}
label="Enable syntax highlighting" label="Enable syntax highlighting"
onChange={(event) => onChange={(event) =>
dispatch( dispatch(
updateSettings({ updateSettings({
enableSyntaxHighlighting: event.currentTarget.checked, enableSyntaxHighlighting: event.currentTarget.checked,
}) })
) )
} }
/> />
<Checkbox <Checkbox
checked={enableLinter} checked={enableLinter}
label="Enable linter" label="Enable linter"
onChange={(event) => onChange={(event) =>
dispatch( dispatch(
updateSettings({ updateSettings({
enableLinter: event.currentTarget.checked, enableLinter: event.currentTarget.checked,
}) })
) )
} }
/> />
</Stack> </Stack>
</Fieldset> </Fieldset>
</Stack>
<Fieldset p="md" legend="Alerts page settings"> <Stack>
<Checkbox <Fieldset p="md" legend="Alerts page settings">
checked={showAnnotations} <Checkbox
label="Show expanded annotations" checked={showAnnotations}
onChange={(event) => label="Show expanded annotations"
dispatch( onChange={(event) =>
updateSettings({ dispatch(
showAnnotations: event.currentTarget.checked, updateSettings({
}) showAnnotations: event.currentTarget.checked,
) })
} )
/> }
</Fieldset> />
</Stack> </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.Dropdown>
</Popover> </Popover>
); );

View file

@ -11,6 +11,7 @@ import {
Alert, Alert,
TextInput, TextInput,
Anchor, Anchor,
Pagination,
} from "@mantine/core"; } from "@mantine/core";
import { useSuspenseAPIQuery } from "../api/api"; import { useSuspenseAPIQuery } from "../api/api";
import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules"; import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules";
@ -18,7 +19,7 @@ import badgeClasses from "../Badge.module.css";
import panelClasses from "../Panel.module.css"; import panelClasses from "../Panel.module.css";
import RuleDefinition from "../components/RuleDefinition"; import RuleDefinition from "../components/RuleDefinition";
import { humanizeDurationRelative, now } from "../lib/formatTime"; import { humanizeDurationRelative, now } from "../lib/formatTime";
import { Fragment, useMemo } from "react"; import { Fragment, useEffect, useMemo } from "react";
import { StateMultiSelect } from "../components/StateMultiSelect"; import { StateMultiSelect } from "../components/StateMultiSelect";
import { IconInfoCircle, IconSearch } from "@tabler/icons-react"; import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
import { LabelBadges } from "../components/LabelBadges"; import { LabelBadges } from "../components/LabelBadges";
@ -26,6 +27,7 @@ import { useSettings } from "../state/settingsSlice";
import { import {
ArrayParam, ArrayParam,
BooleanParam, BooleanParam,
NumberParam,
StringParam, StringParam,
useQueryParam, useQueryParam,
withDefault, withDefault,
@ -33,6 +35,7 @@ import {
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { KVSearch } from "@nexucis/kvsearch"; import { KVSearch } from "@nexucis/kvsearch";
import { inputIconStyle } from "../styles"; import { inputIconStyle } from "../styles";
import CustomInfiniteScroll from "../components/CustomInfiniteScroll";
type AlertsPageData = { type AlertsPageData = {
// How many rules are in each state across all groups. // How many rules are in each state across all groups.
@ -132,6 +135,12 @@ const buildAlertsPageData = (
return pageData; 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() { export default function AlertsPage() {
// Fetch the alerting rules data. // Fetch the alerting rules data.
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({ const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
@ -146,7 +155,7 @@ export default function AlertsPage() {
// Define URL query params. // Define URL query params.
const [stateFilter, setStateFilter] = useQueryParam( const [stateFilter, setStateFilter] = useQueryParam(
"state", "state",
withDefault(ArrayParam, []) withDefault(ArrayParam, emptyStateFilter)
); );
const [searchFilter, setSearchFilter] = useQueryParam( const [searchFilter, setSearchFilter] = useQueryParam(
"search", "search",
@ -158,132 +167,117 @@ export default function AlertsPage() {
withDefault(BooleanParam, true) 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. // Update the page data whenever the fetched data or filters change.
const alertsPageData: AlertsPageData = useMemo( const alertsPageData: AlertsPageData = useMemo(
() => buildAlertsPageData(data.data, debouncedSearch, stateFilter), () => buildAlertsPageData(data.data, debouncedSearch, stateFilter),
[data, stateFilter, debouncedSearch] [data, stateFilter, debouncedSearch]
); );
const shownGroups = showEmptyGroups const shownGroups = useMemo(
? alertsPageData.groups () =>
: alertsPageData.groups.filter((g) => g.rules.length > 0); showEmptyGroups
? alertsPageData.groups
: alertsPageData.groups.filter((g) => g.rules.length > 0),
[alertsPageData.groups, showEmptyGroups]
);
return ( // If we were e.g. on page 10 and the number of total pages decreases to 5 (due to filtering
<Stack mt="xs"> // or changing the max number of items per page), go to the largest possible page.
<Group> const totalPageCount = Math.ceil(shownGroups.length / alertGroupsPerPage);
<StateMultiSelect const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount));
options={["inactive", "pending", "firing"]}
optionClass={(o) => useEffect(() => {
o === "inactive" if (effectiveActivePage !== activePage) {
? badgeClasses.healthOk setActivePage(effectiveActivePage);
: o === "pending" }
? badgeClasses.healthWarn }, [effectiveActivePage, activePage, setActivePage]);
: badgeClasses.healthErr
} const currentPageGroups = useMemo(
optionCount={(o) => () =>
alertsPageData.globalCounts[ shownGroups.slice(
o as keyof typeof alertsPageData.globalCounts (effectiveActivePage - 1) * alertGroupsPerPage,
] effectiveActivePage * alertGroupsPerPage
} ),
placeholder="Filter by rule state" [shownGroups, effectiveActivePage, alertGroupsPerPage]
values={(stateFilter?.filter((v) => v !== null) as string[]) || []} );
onChange={(values) => setStateFilter(values)}
/> // We memoize the actual rendering of the page items to avoid re-rendering
<TextInput // them on every state change. This is especially important when the user
flex={1} // types into the search box, as the search filter changes on every keystroke,
leftSection={<IconSearch style={inputIconStyle} />} // even before debouncing takes place (extracting the filters and results list
placeholder="Filter by rule name or labels" // into separate components would be an alternative to this, but it's kinda
value={searchFilter || ""} // convenient to have in the same file IMO).
onChange={(event) => const renderedPageItems = useMemo(
setSearchFilter(event.currentTarget.value || null) () =>
} currentPageGroups.map((g, i) => (
></TextInput> <Card
</Group> shadow="xs"
{alertsPageData.groups.length === 0 ? ( withBorder
<Alert title="No rules found" icon={<IconInfoCircle />}> p="md"
No rules found. key={i} // TODO: Find a stable and definitely unique key.
</Alert> >
) : ( <Group mb="md" mt="xs" ml="xs" justify="space-between">
!showEmptyGroups && <Group align="baseline">
alertsPageData.groups.length !== shownGroups.length && ( <Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
<Alert {g.name}
title="Hiding groups with no matching rules" </Text>
icon={<IconInfoCircle/>} <Text fz="sm" c="gray.6">
> {g.file}
Hiding {alertsPageData.groups.length - shownGroups.length} empty </Text>
groups due to filters or no rules. </Group>
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyGroups(true)}> <Group>
Show empty groups {g.counts.firing > 0 && (
</Anchor> <Badge className={badgeClasses.healthErr}>
</Alert> firing ({g.counts.firing})
) </Badge>
)} )}
<Stack> {g.counts.pending > 0 && (
{shownGroups.map((g, i) => { <Badge className={badgeClasses.healthWarn}>
return ( pending ({g.counts.pending})
<Card </Badge>
shadow="xs" )}
withBorder {g.counts.inactive > 0 && (
p="md" <Badge className={badgeClasses.healthOk}>
key={i} // TODO: Find a stable and definitely unique key. inactive ({g.counts.inactive})
> </Badge>
<Group mb="md" mt="xs" ml="xs" justify="space-between"> )}
<Group align="baseline"> </Group>
<Text </Group>
fz="xl" {g.counts.total === 0 ? (
fw={600} <Alert title="No rules" icon={<IconInfoCircle />}>
c="var(--mantine-primary-color-filled)" No rules in this group.
> <Anchor
{g.name} ml="md"
</Text> fz="1em"
<Text fz="sm" c="gray.6"> onClick={() => setShowEmptyGroups(false)}
{g.file} >
</Text> Hide empty groups
</Group> </Anchor>
<Group> </Alert>
{g.counts.firing > 0 && ( ) : g.rules.length === 0 ? (
<Badge className={badgeClasses.healthErr}> <Alert title="No matching rules" icon={<IconInfoCircle />}>
firing ({g.counts.firing}) No rules in this group match your filter criteria (omitted{" "}
</Badge> {g.counts.total} filtered rules).
)} <Anchor
{g.counts.pending > 0 && ( ml="md"
<Badge className={badgeClasses.healthWarn}> fz="1em"
pending ({g.counts.pending}) onClick={() => setShowEmptyGroups(false)}
</Badge> >
)} Hide empty groups
{g.counts.inactive > 0 && ( </Anchor>
<Badge className={badgeClasses.healthOk}> </Alert>
inactive ({g.counts.inactive}) ) : (
</Badge> <CustomInfiniteScroll
)} allItems={g.rules}
</Group> child={({ items }) => (
</Group>
{g.counts.total === 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.counts.total} filtered rules).
<Anchor
ml="md"
fz="1em"
onClick={() => setShowEmptyGroups(false)}
>
Hide empty groups
</Anchor>
</Alert>
) : (
<Accordion multiple variant="separated"> <Accordion multiple variant="separated">
{g.rules.map((r, j) => { {items.map((r, j) => {
return ( return (
<Accordion.Item <Accordion.Item
styles={{ styles={{
@ -327,7 +321,7 @@ export default function AlertsPage() {
{r.rule.alerts.length > 0 && ( {r.rule.alerts.length > 0 && (
<Table mt="lg"> <Table mt="lg">
<Table.Thead> <Table.Thead>
<Table.Tr style={{whiteSpace: "nowrap"}}> <Table.Tr style={{ whiteSpace: "nowrap" }}>
<Table.Th>Alert labels</Table.Th> <Table.Th>Alert labels</Table.Th>
<Table.Th>State</Table.Th> <Table.Th>State</Table.Th>
<Table.Th>Active Since</Table.Th> <Table.Th>Active Since</Table.Th>
@ -405,9 +399,71 @@ export default function AlertsPage() {
})} })}
</Accordion> </Accordion>
)} )}
</Card> />
); )}
})} </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>
</Stack> </Stack>
); );

View file

@ -4,6 +4,7 @@ import {
Badge, Badge,
Card, Card,
Group, Group,
Pagination,
rem, rem,
Stack, Stack,
Text, Text,
@ -29,6 +30,10 @@ import { 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 } 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) => { const healthBadgeClass = (state: string) => {
switch (state) { switch (state) {
@ -45,6 +50,23 @@ const healthBadgeClass = (state: string) => {
export default function RulesPage() { export default function RulesPage() {
const { data } = useSuspenseAPIQuery<RulesResult>({ path: `/rules` }); 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 ( return (
<Stack mt="xs"> <Stack mt="xs">
@ -53,157 +75,178 @@ export default function RulesPage() {
No rule groups configured. No rule groups configured.
</Alert> </Alert>
)} )}
{data.data.groups.map((g, i) => ( <Pagination
<Card total={totalPageCount}
shadow="xs" value={effectiveActivePage}
withBorder onChange={setActivePage}
p="md" hideWithOnePage
mb="md" />
key={i} // TODO: Find a stable and definitely unique key. {data.data.groups
> .slice(
<Group mb="md" mt="xs" ml="xs" justify="space-between"> (effectiveActivePage - 1) * ruleGroupsPerPage,
<Group align="baseline"> effectiveActivePage * ruleGroupsPerPage
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)"> )
{g.name} .map((g, i) => (
</Text> <Card
<Text fz="sm" c="gray.6"> shadow="xs"
{g.file} withBorder
</Text> p="md"
mb="md"
key={i} // TODO: Find a stable and definitely unique key.
>
<Group mb="md" mt="xs" ml="xs" 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>
</Group> </Group>
<Group> {g.rules.length === 0 && (
<Tooltip label="Last group evaluation" withArrow> <Alert title="No rules" icon={<IconInfoCircle />}>
<Badge No rules in rule group.
variant="light" </Alert>
className={badgeClasses.statsBadge} )}
styles={{ label: { textTransform: "none" } }} <CustomInfiniteScroll
leftSection={<IconRefresh style={badgeIconStyle} />} allItems={g.rules}
> child={({ items }) => (
last run {humanizeDurationRelative(g.lastEvaluation, now())} <Accordion multiple variant="separated">
</Badge> {items.map((r, j) => (
</Tooltip> <Accordion.Item
<Tooltip label="Duration of last group evaluation" withArrow> styles={{
<Badge item: {
variant="light" // TODO: This transparency hack is an OK workaround to make the collapsed items
className={badgeClasses.statsBadge} // have a different background color than their surrounding group card in dark mode,
styles={{ label: { textTransform: "none" } }} // but it would be better to use CSS to override the light/dark colors for
leftSection={<IconHourglass style={badgeIconStyle} />} // collapsed/expanded accordion items.
> backgroundColor: "#c0c0c015",
took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)} },
</Badge> }}
</Tooltip> key={j}
<Tooltip label="Group evaluation interval" withArrow> value={j.toString()}
<Badge style={{
variant="transparent" borderLeft:
className={badgeClasses.statsBadge} r.health === "err"
styles={{ label: { textTransform: "none" } }} ? "5px solid var(--mantine-color-red-4)"
leftSection={<IconRepeat style={badgeIconStyle} />} : r.health === "unknown"
> ? "5px solid var(--mantine-color-gray-5)"
every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "} : "5px solid var(--mantine-color-green-4)",
</Badge> }}
</Tooltip>
</Group>
</Group>
{g.rules.length === 0 && (
<Alert title="No rules" icon={<IconInfoCircle />}>
No rules in rule group.
</Alert>
)}
<Accordion multiple variant="separated">
{g.rules.map((r, j) => (
<Accordion.Item
styles={{
item: {
// TODO: This transparency hack is an OK workaround to make the collapsed items
// have a different background color than their surrounding group card in dark mode,
// but it would be better to use CSS to override the light/dark colors for
// collapsed/expanded accordion items.
backgroundColor: "#c0c0c015",
},
}}
key={j}
value={j.toString()}
style={{
borderLeft:
r.health === "err"
? "5px solid var(--mantine-color-red-4)"
: r.health === "unknown"
? "5px solid var(--mantine-color-gray-5)"
: "5px solid var(--mantine-color-green-4)",
}}
>
<Accordion.Control>
<Group justify="space-between" mr="lg">
<Group gap="xs" wrap="nowrap">
{r.type === "alerting" ? (
<Tooltip label="Alerting rule" withArrow>
<IconBell
style={{ width: rem(15), height: rem(15) }}
/>
</Tooltip>
) : (
<Tooltip label="Recording rule" withArrow>
<IconTimeline
style={{ width: rem(15), height: rem(15) }}
/>
</Tooltip>
)}
<Text>{r.name}</Text>
</Group>
<Group gap="xs">
<Group gap="xs" wrap="wrap">
<Tooltip label="Last rule evaluation" withArrow>
<Badge
variant="light"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRefresh style={badgeIconStyle} />}
>
{humanizeDurationRelative(r.lastEvaluation, now())}
</Badge>
</Tooltip>
<Tooltip
label="Duration of last rule evaluation"
withArrow
>
<Badge
variant="light"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={
<IconHourglass style={badgeIconStyle} />
}
>
{humanizeDuration(
parseFloat(r.evaluationTime) * 1000
)}
</Badge>
</Tooltip>
</Group>
<Badge className={healthBadgeClass(r.health)}>
{r.health}
</Badge>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<RuleDefinition rule={r} />
{r.lastError && (
<Alert
color="red"
mt="sm"
title="Rule failed to evaluate"
icon={<IconAlertTriangle />}
> >
<strong>Error:</strong> {r.lastError} <Accordion.Control>
</Alert> <Group justify="space-between" mr="lg">
)} <Group gap="xs" wrap="nowrap">
</Accordion.Panel> {r.type === "alerting" ? (
</Accordion.Item> <Tooltip label="Alerting rule" withArrow>
))} <IconBell
</Accordion> style={{ width: rem(15), height: rem(15) }}
</Card> />
))} </Tooltip>
) : (
<Tooltip label="Recording rule" withArrow>
<IconTimeline
style={{ width: rem(15), height: rem(15) }}
/>
</Tooltip>
)}
<Text>{r.name}</Text>
</Group>
<Group gap="xs">
<Group gap="xs" wrap="wrap">
<Tooltip label="Last rule evaluation" withArrow>
<Badge
variant="light"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={
<IconRefresh style={badgeIconStyle} />
}
>
{humanizeDurationRelative(
r.lastEvaluation,
now()
)}
</Badge>
</Tooltip>
<Tooltip
label="Duration of last rule evaluation"
withArrow
>
<Badge
variant="light"
className={badgeClasses.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={
<IconHourglass style={badgeIconStyle} />
}
>
{humanizeDuration(
parseFloat(r.evaluationTime) * 1000
)}
</Badge>
</Tooltip>
</Group>
<Badge className={healthBadgeClass(r.health)}>
{r.health}
</Badge>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<RuleDefinition rule={r} />
{r.lastError && (
<Alert
color="red"
mt="sm"
title="Rule failed to evaluate"
icon={<IconAlertTriangle />}
>
<strong>Error:</strong> {r.lastError}
</Alert>
)}
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
)}
/>
</Card>
))}
</Stack> </Stack>
); );
} }

View file

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

View file

@ -14,6 +14,8 @@ interface Settings {
enableSyntaxHighlighting: boolean; enableSyntaxHighlighting: boolean;
enableLinter: boolean; enableLinter: boolean;
showAnnotations: boolean; showAnnotations: boolean;
ruleGroupsPerPage: number;
alertGroupsPerPage: number;
} }
// Declared/defined in public/index.html, value replaced by Prometheus when serving bundle. // Declared/defined in public/index.html, value replaced by Prometheus when serving bundle.
@ -29,6 +31,8 @@ export const localStorageKeyEnableSyntaxHighlighting =
"settings.enableSyntaxHighlighting"; "settings.enableSyntaxHighlighting";
export const localStorageKeyEnableLinter = "settings.enableLinter"; export const localStorageKeyEnableLinter = "settings.enableLinter";
export const localStorageKeyShowAnnotations = "settings.showAnnotations"; 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 // 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 // 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, localStorageKeyShowAnnotations,
true true
), ),
ruleGroupsPerPage: initializeFromLocalStorage<number>(
localStorageKeyRuleGroupsPerPage,
10
),
alertGroupsPerPage: initializeFromLocalStorage<number>(
localStorageKeyAlertGroupsPerPage,
10
),
}; };
export const settingsSlice = createSlice({ export const settingsSlice = createSlice({