Add filtering to alerts page

Signed-off-by: Julius Volz <julius.volz@gmail.com>
pull/14872/head
Julius Volz 3 months ago
parent 4efd47741e
commit d6e5e39bf7

@ -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[];
}; };

@ -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>
); );

@ -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(
"search",
withDefault(StringParam, "")
);
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
const [showEmptyGroups, setShowEmptyGroups] = useQueryParam(
"showEmptyGroups",
withDefault(BooleanParam, true)
);
data.data.groups.forEach((el) => // Update the page data whenever the fetched data or filters change.
el.rules.forEach((r) => ruleStatsCount[r.state]++) 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,138 +241,165 @@ export default function AlertsPage() {
{g.file} {g.file}
</Text> </Text>
</Group> </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> </Group>
{filteredRules.length === 0 && ( {g.counts.total === 0 ? (
<Alert <Alert title="No rules" icon={<IconInfoCircle />}>
title="No matching rules" No rules in this group.
icon={<IconInfoCircle size={14} />} <Anchor
> ml="md"
No rules found that match your filter criteria. fz="1em"
onClick={() => setShowEmptyGroups(false)}
>
Hide empty groups
</Anchor>
</Alert> </Alert>
)} ) : g.rules.length === 0 ? (
<Accordion multiple variant="separated"> <Alert title="No matching rules" icon={<IconInfoCircle />}>
{filteredRules.map((r, j) => { No rules in this group match your filter criteria (omitted{" "}
const numFiring = r.alerts.filter( {g.counts.total} filtered rules).
(a) => a.state === "firing" <Anchor
).length; ml="md"
const numPending = r.alerts.filter( fz="1em"
(a) => a.state === "pending" onClick={() => setShowEmptyGroups(false)}
).length; >
Hide empty groups
return ( </Anchor>
<Accordion.Item </Alert>
styles={{ ) : (
item: { <Accordion multiple variant="separated">
// TODO: This transparency hack is an OK workaround to make the collapsed items {g.rules.map((r, j) => {
// have a different background color than their surrounding group card in dark mode, return (
// but it would be better to use CSS to override the light/dark colors for <Accordion.Item
// collapsed/expanded accordion items. styles={{
backgroundColor: "#c0c0c015", 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,
key={j} // but it would be better to use CSS to override the light/dark colors for
value={j.toString()} // collapsed/expanded accordion items.
className={ backgroundColor: "#c0c0c015",
numFiring > 0 },
? panelClasses.panelHealthErr }}
: numPending > 0 key={j}
? panelClasses.panelHealthWarn value={j.toString()}
: panelClasses.panelHealthOk className={
} r.counts.firing > 0
> ? panelClasses.panelHealthErr
<Accordion.Control> : r.counts.pending > 0
<Group wrap="nowrap" justify="space-between" mr="lg"> ? panelClasses.panelHealthWarn
<Text>{r.name}</Text> : panelClasses.panelHealthOk
<Group gap="xs"> }
{numFiring > 0 && ( >
<Badge className={badgeClasses.healthErr}> <Accordion.Control>
firing ({numFiring}) <Group wrap="nowrap" justify="space-between" mr="lg">
</Badge> <Text>{r.rule.name}</Text>
)} <Group gap="xs">
{numPending > 0 && ( {r.counts.firing > 0 && (
<Badge className={badgeClasses.healthWarn}> <Badge className={badgeClasses.healthErr}>
pending ({numPending}) firing ({r.counts.firing})
</Badge> </Badge>
)} )}
{r.counts.pending > 0 && (
<Badge className={badgeClasses.healthWarn}>
pending ({r.counts.pending})
</Badge>
)}
</Group>
</Group> </Group>
</Group> </Accordion.Control>
</Accordion.Control> <Accordion.Panel>
<Accordion.Panel> <RuleDefinition rule={r.rule} />
<RuleDefinition rule={r} /> {r.rule.alerts.length > 0 && (
{r.alerts.length > 0 && ( <Table mt="lg">
<Table mt="lg"> <Table.Thead>
<Table.Thead> <Table.Tr>
<Table.Tr> <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> <Table.Th>Value</Table.Th>
<Table.Th>Value</Table.Th> </Table.Tr>
</Table.Tr> </Table.Thead>
</Table.Thead> <Table.Tbody>
<Table.Tbody> {r.rule.type === "alerting" &&
{r.type === "alerting" && r.rule.alerts.map((a, k) => (
r.alerts.map((a, k) => ( <Fragment key={k}>
<Fragment key={k}>
<Table.Tr>
<Table.Td>
<LabelBadges labels={a.labels} />
</Table.Td>
<Table.Td>
<Badge
className={
a.state === "firing"
? badgeClasses.healthErr
: badgeClasses.healthWarn
}
>
{a.state}
</Badge>
</Table.Td>
<Table.Td>
<Tooltip label={a.activeAt}>
<Box>
{humanizeDurationRelative(
a.activeAt,
now(),
""
)}
</Box>
</Tooltip>
</Table.Td>
<Table.Td>{a.value}</Table.Td>
</Table.Tr>
{showAnnotations && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={4}> <Table.Td>
<Table mt="md" mb="xl"> <LabelBadges labels={a.labels} />
<Table.Tbody>
{Object.entries(
a.annotations
).map(([k, v]) => (
<Table.Tr key={k}>
<Table.Th>{k}</Table.Th>
<Table.Td>{v}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td> </Table.Td>
<Table.Td>
<Badge
className={
a.state === "firing"
? badgeClasses.healthErr
: badgeClasses.healthWarn
}
>
{a.state}
</Badge>
</Table.Td>
<Table.Td>
<Tooltip label={a.activeAt}>
<Box>
{humanizeDurationRelative(
a.activeAt,
now(),
""
)}
</Box>
</Tooltip>
</Table.Td>
<Table.Td>{a.value}</Table.Td>
</Table.Tr> </Table.Tr>
)} {showAnnotations && (
</Fragment> <Table.Tr>
))} <Table.Td colSpan={4}>
</Table.Tbody> <Table mt="md" mb="xl">
</Table> <Table.Tbody>
)} {Object.entries(
</Accordion.Panel> a.annotations
</Accordion.Item> ).map(([k, v]) => (
); <Table.Tr key={k}>
})} <Table.Th>{k}</Table.Th>
</Accordion> <Table.Td>{v}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
)}
</Fragment>
))}
</Table.Tbody>
</Table>
)}
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
)}
</Card> </Card>
); );
})} })}
</Stack> </Stack>
</> </Stack>
); );
} }

@ -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;

@ -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),

Loading…
Cancel
Save