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

View file

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

View file

@ -8,24 +8,133 @@ import {
Tooltip,
Box,
Stack,
Input,
Alert,
TextInput,
Anchor,
} from "@mantine/core";
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 panelClasses from "../Panel.module.css";
import RuleDefinition from "../components/RuleDefinition";
import { humanizeDurationRelative, now } from "../lib/formatTime";
import { Fragment } from "react";
import { Fragment, useMemo } from "react";
import { StateMultiSelect } from "../components/StateMultiSelect";
import { useAppDispatch, useAppSelector } from "../state/hooks";
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
import { LabelBadges } from "../components/LabelBadges";
import { updateAlertFilters } from "../state/alertsPageSlice";
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() {
// Fetch the alerting rules data.
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
path: `/rules`,
params: {
@ -33,23 +142,36 @@ export default function AlertsPage() {
},
});
const dispatch = useAppDispatch();
const { showAnnotations } = useSettings();
const filters = useAppSelector((state) => state.alertsPage.filters);
const ruleStatsCount = {
inactive: 0,
pending: 0,
firing: 0,
};
data.data.groups.forEach((el) =>
el.rules.forEach((r) => ruleStatsCount[r.state]++)
// Define URL query params.
const [stateFilter, setStateFilter] = useQueryParam(
"state",
withDefault(ArrayParam, [])
);
const [searchFilter, setSearchFilter] = useQueryParam(
"search",
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 (
<>
<Group mb="md" mt="xs">
<Stack mt="xs">
<Group>
<StateMultiSelect
options={["inactive", "pending", "firing"]}
optionClass={(o) =>
@ -59,21 +181,46 @@ export default function AlertsPage() {
? badgeClasses.healthWarn
: badgeClasses.healthErr
}
placeholder="Filter by alert state"
values={filters.state}
onChange={(values) => dispatch(updateAlertFilters({ state: values }))}
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)}
/>
<Input
<TextInput
flex={1}
leftSection={<IconSearch size={14} />}
placeholder="Filter by alert name or labels"
></Input>
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 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>
{data.data.groups.map((g, i) => {
const filteredRules = g.rules.filter(
(r) => filters.state.length === 0 || filters.state.includes(r.state)
);
{shownGroups.map((g, i) => {
return (
<Card
shadow="xs"
@ -94,24 +241,50 @@ export default function AlertsPage() {
{g.file}
</Text>
</Group>
</Group>
{filteredRules.length === 0 && (
<Alert
title="No matching rules"
icon={<IconInfoCircle size={14} />}
>
No rules found that match your filter criteria.
</Alert>
<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>
) : (
<Accordion multiple variant="separated">
{filteredRules.map((r, j) => {
const numFiring = r.alerts.filter(
(a) => a.state === "firing"
).length;
const numPending = r.alerts.filter(
(a) => a.state === "pending"
).length;
{g.rules.map((r, j) => {
return (
<Accordion.Item
styles={{
@ -126,33 +299,33 @@ export default function AlertsPage() {
key={j}
value={j.toString()}
className={
numFiring > 0
r.counts.firing > 0
? panelClasses.panelHealthErr
: numPending > 0
: r.counts.pending > 0
? panelClasses.panelHealthWarn
: panelClasses.panelHealthOk
}
>
<Accordion.Control>
<Group wrap="nowrap" justify="space-between" mr="lg">
<Text>{r.name}</Text>
<Text>{r.rule.name}</Text>
<Group gap="xs">
{numFiring > 0 && (
{r.counts.firing > 0 && (
<Badge className={badgeClasses.healthErr}>
firing ({numFiring})
firing ({r.counts.firing})
</Badge>
)}
{numPending > 0 && (
{r.counts.pending > 0 && (
<Badge className={badgeClasses.healthWarn}>
pending ({numPending})
pending ({r.counts.pending})
</Badge>
)}
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<RuleDefinition rule={r} />
{r.alerts.length > 0 && (
<RuleDefinition rule={r.rule} />
{r.rule.alerts.length > 0 && (
<Table mt="lg">
<Table.Thead>
<Table.Tr>
@ -163,8 +336,8 @@ export default function AlertsPage() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{r.type === "alerting" &&
r.alerts.map((a, k) => (
{r.rule.type === "alerting" &&
r.rule.alerts.map((a, k) => (
<Fragment key={k}>
<Table.Tr>
<Table.Td>
@ -222,10 +395,11 @@ export default function AlertsPage() {
);
})}
</Accordion>
)}
</Card>
);
})}
</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 settingsSlice from "./settingsSlice";
import targetsPageSlice from "./targetsPageSlice";
import alertsPageSlice from "./alertsPageSlice";
import { localStorageMiddleware } from "./localStorageMiddleware";
const store = configureStore({
@ -10,7 +9,6 @@ const store = configureStore({
settings: settingsSlice,
queryPage: queryPageSlice,
targetsPage: targetsPageSlice,
alertsPage: alertsPageSlice,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(localStorageMiddleware.middleware),