Merge pull request #15677 from prometheus/juliusv/rule-pagination

Paginate rule groups, add infinite scroll to rules within groups
This commit is contained in:
Julius Volz 2024-12-18 08:13:25 +01:00 committed by GitHub
commit 8e9aff75e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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,82 +39,126 @@ const SettingsMenu: FC = () => {
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Stack>
<Fieldset p="md" legend="Global settings">
<Checkbox
checked={useLocalTime}
label="Use local time"
onChange={(event) =>
dispatch(
updateSettings({ useLocalTime: event.currentTarget.checked })
)
}
/>
</Fieldset>
<Group align="flex-start">
<Stack>
<Fieldset p="md" legend="Global settings">
<Checkbox
checked={useLocalTime}
label="Use local time"
onChange={(event) =>
dispatch(
updateSettings({
useLocalTime: event.currentTarget.checked,
})
)
}
/>
</Fieldset>
<Fieldset p="md" legend="Query page settings">
<Stack>
<Checkbox
checked={enableQueryHistory}
label="Enable query history"
onChange={(event) =>
dispatch(
updateSettings({
enableQueryHistory: event.currentTarget.checked,
})
)
}
/>
<Checkbox
checked={enableAutocomplete}
label="Enable autocomplete"
onChange={(event) =>
dispatch(
updateSettings({
enableAutocomplete: event.currentTarget.checked,
})
)
}
/>
<Checkbox
checked={enableSyntaxHighlighting}
label="Enable syntax highlighting"
onChange={(event) =>
dispatch(
updateSettings({
enableSyntaxHighlighting: event.currentTarget.checked,
})
)
}
/>
<Checkbox
checked={enableLinter}
label="Enable linter"
onChange={(event) =>
dispatch(
updateSettings({
enableLinter: event.currentTarget.checked,
})
)
}
/>
</Stack>
</Fieldset>
<Fieldset p="md" legend="Query page settings">
<Stack>
<Checkbox
checked={enableQueryHistory}
label="Enable query history"
onChange={(event) =>
dispatch(
updateSettings({
enableQueryHistory: event.currentTarget.checked,
})
)
}
/>
<Checkbox
checked={enableAutocomplete}
label="Enable autocomplete"
onChange={(event) =>
dispatch(
updateSettings({
enableAutocomplete: event.currentTarget.checked,
})
)
}
/>
<Checkbox
checked={enableSyntaxHighlighting}
label="Enable syntax highlighting"
onChange={(event) =>
dispatch(
updateSettings({
enableSyntaxHighlighting: event.currentTarget.checked,
})
)
}
/>
<Checkbox
checked={enableLinter}
label="Enable linter"
onChange={(event) =>
dispatch(
updateSettings({
enableLinter: event.currentTarget.checked,
})
)
}
/>
</Stack>
</Fieldset>
</Stack>
<Fieldset p="md" legend="Alerts page settings">
<Checkbox
checked={showAnnotations}
label="Show expanded annotations"
onChange={(event) =>
dispatch(
updateSettings({
showAnnotations: event.currentTarget.checked,
})
)
}
/>
</Fieldset>
</Stack>
<Stack>
<Fieldset p="md" legend="Alerts page settings">
<Checkbox
checked={showAnnotations}
label="Show expanded annotations"
onChange={(event) =>
dispatch(
updateSettings({
showAnnotations: event.currentTarget.checked,
})
)
}
/>
</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,132 +167,117 @@ 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
? alertsPageData.groups
: alertsPageData.groups.filter((g) => g.rules.length > 0);
const shownGroups = useMemo(
() =>
showEmptyGroups
? alertsPageData.groups
: 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
}
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 (
<Card
shadow="xs"
withBorder
p="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>
{g.counts.firing > 0 && (
<Badge className={badgeClasses.healthErr}>
firing ({g.counts.firing})
</Badge>
)}
{g.counts.pending > 0 && (
<Badge className={badgeClasses.healthWarn}>
pending ({g.counts.pending})
</Badge>
)}
{g.counts.inactive > 0 && (
<Badge className={badgeClasses.healthOk}>
inactive ({g.counts.inactive})
</Badge>
)}
</Group>
</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>
) : (
// 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);
}
}, [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
p="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>
{g.counts.firing > 0 && (
<Badge className={badgeClasses.healthErr}>
firing ({g.counts.firing})
</Badge>
)}
{g.counts.pending > 0 && (
<Badge className={badgeClasses.healthWarn}>
pending ({g.counts.pending})
</Badge>
)}
{g.counts.inactive > 0 && (
<Badge className={badgeClasses.healthOk}>
inactive ({g.counts.inactive})
</Badge>
)}
</Group>
</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>
) : (
<CustomInfiniteScroll
allItems={g.rules}
child={({ items }) => (
<Accordion multiple variant="separated">
{g.rules.map((r, j) => {
{items.map((r, j) => {
return (
<Accordion.Item
styles={{
@ -327,7 +321,7 @@ export default function AlertsPage() {
{r.rule.alerts.length > 0 && (
<Table mt="lg">
<Table.Thead>
<Table.Tr style={{whiteSpace: "nowrap"}}>
<Table.Tr style={{ whiteSpace: "nowrap" }}>
<Table.Th>Alert labels</Table.Th>
<Table.Th>State</Table.Th>
<Table.Th>Active Since</Table.Th>
@ -405,9 +399,71 @@ export default function AlertsPage() {
})}
</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>
);

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,157 +75,178 @@ export default function RulesPage() {
No rule groups configured.
</Alert>
)}
{data.data.groups.map((g, i) => (
<Card
shadow="xs"
withBorder
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>
<Pagination
total={totalPageCount}
value={effectiveActivePage}
onChange={setActivePage}
hideWithOnePage
/>
{data.data.groups
.slice(
(effectiveActivePage - 1) * ruleGroupsPerPage,
effectiveActivePage * ruleGroupsPerPage
)
.map((g, i) => (
<Card
shadow="xs"
withBorder
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>
<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.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 />}
{g.rules.length === 0 && (
<Alert title="No rules" icon={<IconInfoCircle />}>
No rules in rule group.
</Alert>
)}
<CustomInfiniteScroll
allItems={g.rules}
child={({ items }) => (
<Accordion multiple variant="separated">
{items.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)",
}}
>
<strong>Error:</strong> {r.lastError}
</Alert>
)}
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Card>
))}
<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}
</Alert>
)}
</Accordion.Panel>
</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({