Add filtering to alerts page

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-08-28 14:21:52 +02:00
parent 4efd47741e
commit d6e5e39bf7
5 changed files with 335 additions and 188 deletions

View file

@ -18,7 +18,7 @@ type CommonRuleFields = {
lastEvaluation: string; lastEvaluation: string;
}; };
type AlertingRule = { export type AlertingRule = {
type: "alerting"; type: "alerting";
// For alerting rules, the 'labels' field is always present, even when there are no labels. // For alerting rules, the 'labels' field is always present, even when there are no labels.
labels: Record<string, string>; labels: Record<string, string>;
@ -46,7 +46,7 @@ interface RuleGroup {
lastEvaluation: string; lastEvaluation: string;
} }
type AlertingRuleGroup = Omit<RuleGroup, "rules"> & { export type AlertingRuleGroup = Omit<RuleGroup, "rules"> & {
rules: AlertingRule[]; rules: AlertingRule[];
}; };

View file

@ -33,6 +33,7 @@ export function StatePill({ value, onRemove, ...others }: StatePillProps) {
interface StateMultiSelectProps { interface StateMultiSelectProps {
options: string[]; options: string[];
optionClass: (option: string) => string; optionClass: (option: string) => string;
optionCount?: (option: string) => number;
placeholder: string; placeholder: string;
values: string[]; values: string[];
onChange: (values: string[]) => void; onChange: (values: string[]) => void;
@ -41,6 +42,7 @@ interface StateMultiSelectProps {
export const StateMultiSelect: FC<StateMultiSelectProps> = ({ export const StateMultiSelect: FC<StateMultiSelectProps> = ({
options, options,
optionClass, optionClass,
optionCount,
placeholder, placeholder,
values, values,
onChange, onChange,
@ -60,7 +62,7 @@ export const StateMultiSelect: FC<StateMultiSelectProps> = ({
const renderedValues = values.map((item) => ( const renderedValues = values.map((item) => (
<StatePill <StatePill
value={item} value={optionCount ? `${item} (${optionCount(item)})` : item}
className={optionClass(item)} className={optionClass(item)}
onRemove={() => handleValueRemove(item)} onRemove={() => handleValueRemove(item)}
key={item} key={item}
@ -123,7 +125,12 @@ export const StateMultiSelect: FC<StateMultiSelectProps> = ({
{values.includes(value) ? ( {values.includes(value) ? (
<CheckIcon size={12} color="gray" /> <CheckIcon size={12} color="gray" />
) : null} ) : null}
<StatePill value={value} className={optionClass(value)} /> <StatePill
value={
optionCount ? `${value} (${optionCount(value)})` : value
}
className={optionClass(value)}
/>
</Group> </Group>
</Combobox.Option> </Combobox.Option>
); );

View file

@ -8,24 +8,133 @@ import {
Tooltip, Tooltip,
Box, Box,
Stack, Stack,
Input,
Alert, Alert,
TextInput,
Anchor,
} from "@mantine/core"; } from "@mantine/core";
import { useSuspenseAPIQuery } from "../api/api"; import { useSuspenseAPIQuery } from "../api/api";
import { AlertingRulesResult } from "../api/responseTypes/rules"; import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules";
import badgeClasses from "../Badge.module.css"; 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 } from "react"; import { Fragment, useMemo } from "react";
import { StateMultiSelect } from "../components/StateMultiSelect"; import { StateMultiSelect } from "../components/StateMultiSelect";
import { useAppDispatch, useAppSelector } from "../state/hooks";
import { IconInfoCircle, IconSearch } from "@tabler/icons-react"; import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
import { LabelBadges } from "../components/LabelBadges"; import { LabelBadges } from "../components/LabelBadges";
import { updateAlertFilters } from "../state/alertsPageSlice";
import { useSettings } from "../state/settingsSlice"; import { useSettings } from "../state/settingsSlice";
import {
ArrayParam,
BooleanParam,
StringParam,
useQueryParam,
withDefault,
} from "use-query-params";
import { useDebouncedValue } from "@mantine/hooks";
import { KVSearch } from "@nexucis/kvsearch";
type AlertsPageData = {
// How many rules are in each state across all groups.
globalCounts: {
inactive: number;
pending: number;
firing: number;
};
groups: {
name: string;
file: string;
// How many rules are in each state for this group.
counts: {
total: number;
inactive: number;
pending: number;
firing: number;
};
rules: {
rule: AlertingRule;
// How many alerts are in each state for this rule.
counts: {
firing: number;
pending: number;
};
}[];
}[];
};
const kvSearch = new KVSearch<AlertingRule>({
shouldSort: true,
indexedKeys: ["name", "labels", ["labels", /.*/]],
});
const buildAlertsPageData = (
data: AlertingRulesResult,
debouncedSearch: string,
stateFilter: (string | null)[]
) => {
const pageData: AlertsPageData = {
globalCounts: {
inactive: 0,
pending: 0,
firing: 0,
},
groups: [],
};
for (const group of data.groups) {
const groupCounts = {
total: 0,
inactive: 0,
pending: 0,
firing: 0,
};
for (const r of group.rules) {
groupCounts.total++;
switch (r.state) {
case "inactive":
pageData.globalCounts.inactive++;
groupCounts.inactive++;
break;
case "firing":
pageData.globalCounts.firing++;
groupCounts.firing++;
break;
case "pending":
pageData.globalCounts.pending++;
groupCounts.pending++;
break;
default:
throw new Error(`Unknown rule state: ${r.state}`);
}
}
const filteredRules: AlertingRule[] = (
debouncedSearch === ""
? group.rules
: kvSearch
.filter(debouncedSearch, group.rules)
.map((value) => value.original)
).filter((r) => stateFilter.length === 0 || stateFilter.includes(r.state));
pageData.groups.push({
name: group.name,
file: group.file,
counts: groupCounts,
rules: filteredRules.map((r) => ({
rule: r,
counts: {
firing: r.alerts.filter((a) => a.state === "firing").length,
pending: r.alerts.filter((a) => a.state === "pending").length,
},
})),
});
}
return pageData;
};
export default function AlertsPage() { export default function AlertsPage() {
// Fetch the alerting rules data.
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({ const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
path: `/rules`, path: `/rules`,
params: { params: {
@ -33,23 +142,36 @@ export default function AlertsPage() {
}, },
}); });
const dispatch = useAppDispatch();
const { showAnnotations } = useSettings(); const { showAnnotations } = useSettings();
const filters = useAppSelector((state) => state.alertsPage.filters);
const ruleStatsCount = { // Define URL query params.
inactive: 0, const [stateFilter, setStateFilter] = useQueryParam(
pending: 0, "state",
firing: 0, withDefault(ArrayParam, [])
}; );
const [searchFilter, setSearchFilter] = useQueryParam(
data.data.groups.forEach((el) => "search",
el.rules.forEach((r) => ruleStatsCount[r.state]++) withDefault(StringParam, "")
);
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
const [showEmptyGroups, setShowEmptyGroups] = useQueryParam(
"showEmptyGroups",
withDefault(BooleanParam, true)
); );
// 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);
return ( return (
<> <Stack mt="xs">
<Group mb="md" mt="xs"> <Group>
<StateMultiSelect <StateMultiSelect
options={["inactive", "pending", "firing"]} options={["inactive", "pending", "firing"]}
optionClass={(o) => optionClass={(o) =>
@ -59,21 +181,46 @@ export default function AlertsPage() {
? badgeClasses.healthWarn ? badgeClasses.healthWarn
: badgeClasses.healthErr : badgeClasses.healthErr
} }
placeholder="Filter by alert state" optionCount={(o) =>
values={filters.state} alertsPageData.globalCounts[
onChange={(values) => dispatch(updateAlertFilters({ state: values }))} o as keyof typeof alertsPageData.globalCounts
]
}
placeholder="Filter by rule state"
values={(stateFilter?.filter((v) => v !== null) as string[]) || []}
onChange={(values) => setStateFilter(values)}
/> />
<Input <TextInput
flex={1} flex={1}
leftSection={<IconSearch size={14} />} leftSection={<IconSearch size={14} />}
placeholder="Filter by alert name or labels" placeholder="Filter by rule name or labels"
></Input> value={searchFilter || ""}
onChange={(event) =>
setSearchFilter(event.currentTarget.value || null)
}
></TextInput>
</Group> </Group>
{alertsPageData.groups.length === 0 ? (
<Alert title="No rules found" icon={<IconInfoCircle size={14} />}>
No rules found.
</Alert>
) : (
!showEmptyGroups &&
alertsPageData.groups.length !== shownGroups.length && (
<Alert
title="Hiding groups with no matching rules"
icon={<IconInfoCircle size={14} />}
>
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> <Stack>
{data.data.groups.map((g, i) => { {shownGroups.map((g, i) => {
const filteredRules = g.rules.filter(
(r) => filters.state.length === 0 || filters.state.includes(r.state)
);
return ( return (
<Card <Card
shadow="xs" shadow="xs"
@ -94,24 +241,50 @@ export default function AlertsPage() {
{g.file} {g.file}
</Text> </Text>
</Group> </Group>
</Group> <Group>
{filteredRules.length === 0 && ( {g.counts.firing > 0 && (
<Alert <Badge className={badgeClasses.healthErr}>
title="No matching rules" firing ({g.counts.firing})
icon={<IconInfoCircle size={14} />} </Badge>
>
No rules found that match your filter criteria.
</Alert>
)} )}
{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>
) : (
<Accordion multiple variant="separated"> <Accordion multiple variant="separated">
{filteredRules.map((r, j) => { {g.rules.map((r, j) => {
const numFiring = r.alerts.filter(
(a) => a.state === "firing"
).length;
const numPending = r.alerts.filter(
(a) => a.state === "pending"
).length;
return ( return (
<Accordion.Item <Accordion.Item
styles={{ styles={{
@ -126,33 +299,33 @@ export default function AlertsPage() {
key={j} key={j}
value={j.toString()} value={j.toString()}
className={ className={
numFiring > 0 r.counts.firing > 0
? panelClasses.panelHealthErr ? panelClasses.panelHealthErr
: numPending > 0 : r.counts.pending > 0
? panelClasses.panelHealthWarn ? panelClasses.panelHealthWarn
: panelClasses.panelHealthOk : panelClasses.panelHealthOk
} }
> >
<Accordion.Control> <Accordion.Control>
<Group wrap="nowrap" justify="space-between" mr="lg"> <Group wrap="nowrap" justify="space-between" mr="lg">
<Text>{r.name}</Text> <Text>{r.rule.name}</Text>
<Group gap="xs"> <Group gap="xs">
{numFiring > 0 && ( {r.counts.firing > 0 && (
<Badge className={badgeClasses.healthErr}> <Badge className={badgeClasses.healthErr}>
firing ({numFiring}) firing ({r.counts.firing})
</Badge> </Badge>
)} )}
{numPending > 0 && ( {r.counts.pending > 0 && (
<Badge className={badgeClasses.healthWarn}> <Badge className={badgeClasses.healthWarn}>
pending ({numPending}) pending ({r.counts.pending})
</Badge> </Badge>
)} )}
</Group> </Group>
</Group> </Group>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<RuleDefinition rule={r} /> <RuleDefinition rule={r.rule} />
{r.alerts.length > 0 && ( {r.rule.alerts.length > 0 && (
<Table mt="lg"> <Table mt="lg">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
@ -163,8 +336,8 @@ export default function AlertsPage() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{r.type === "alerting" && {r.rule.type === "alerting" &&
r.alerts.map((a, k) => ( r.rule.alerts.map((a, k) => (
<Fragment key={k}> <Fragment key={k}>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
@ -222,10 +395,11 @@ export default function AlertsPage() {
); );
})} })}
</Accordion> </Accordion>
)}
</Card> </Card>
); );
})} })}
</Stack> </Stack>
</> </Stack>
); );
} }

View file

@ -1,32 +0,0 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
interface AlertFilters {
state: string[];
}
interface AlertsPage {
filters: AlertFilters;
}
const initialState: AlertsPage = {
filters: {
state: [],
},
};
export const alertsPageSlice = createSlice({
name: "alertsPage",
initialState,
reducers: {
updateAlertFilters: (
state,
{ payload }: PayloadAction<Partial<AlertFilters>>
) => {
Object.assign(state.filters, payload);
},
},
});
export const { updateAlertFilters } = alertsPageSlice.actions;
export default alertsPageSlice.reducer;

View file

@ -2,7 +2,6 @@ import { configureStore } from "@reduxjs/toolkit";
import queryPageSlice from "./queryPageSlice"; import queryPageSlice from "./queryPageSlice";
import settingsSlice from "./settingsSlice"; import settingsSlice from "./settingsSlice";
import targetsPageSlice from "./targetsPageSlice"; import targetsPageSlice from "./targetsPageSlice";
import alertsPageSlice from "./alertsPageSlice";
import { localStorageMiddleware } from "./localStorageMiddleware"; import { localStorageMiddleware } from "./localStorageMiddleware";
const store = configureStore({ const store = configureStore({
@ -10,7 +9,6 @@ const store = configureStore({
settings: settingsSlice, settings: settingsSlice,
queryPage: queryPageSlice, queryPage: queryPageSlice,
targetsPage: targetsPageSlice, targetsPage: targetsPageSlice,
alertsPage: alertsPageSlice,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(localStorageMiddleware.middleware), getDefaultMiddleware().prepend(localStorageMiddleware.middleware),