mirror of
https://github.com/prometheus/prometheus.git
synced 2024-12-26 06:04:05 -08:00
Add filtering to alerts page
Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
4efd47741e
commit
d6e5e39bf7
|
@ -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[];
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,138 +241,165 @@ export default function AlertsPage() {
|
|||
{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>
|
||||
{filteredRules.length === 0 && (
|
||||
<Alert
|
||||
title="No matching rules"
|
||||
icon={<IconInfoCircle size={14} />}
|
||||
>
|
||||
No rules found that match your filter criteria.
|
||||
{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>
|
||||
)}
|
||||
<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;
|
||||
|
||||
return (
|
||||
<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()}
|
||||
className={
|
||||
numFiring > 0
|
||||
? panelClasses.panelHealthErr
|
||||
: numPending > 0
|
||||
? panelClasses.panelHealthWarn
|
||||
: panelClasses.panelHealthOk
|
||||
}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap" justify="space-between" mr="lg">
|
||||
<Text>{r.name}</Text>
|
||||
<Group gap="xs">
|
||||
{numFiring > 0 && (
|
||||
<Badge className={badgeClasses.healthErr}>
|
||||
firing ({numFiring})
|
||||
</Badge>
|
||||
)}
|
||||
{numPending > 0 && (
|
||||
<Badge className={badgeClasses.healthWarn}>
|
||||
pending ({numPending})
|
||||
</Badge>
|
||||
)}
|
||||
) : 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">
|
||||
{g.rules.map((r, j) => {
|
||||
return (
|
||||
<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()}
|
||||
className={
|
||||
r.counts.firing > 0
|
||||
? panelClasses.panelHealthErr
|
||||
: r.counts.pending > 0
|
||||
? panelClasses.panelHealthWarn
|
||||
: panelClasses.panelHealthOk
|
||||
}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap" justify="space-between" mr="lg">
|
||||
<Text>{r.rule.name}</Text>
|
||||
<Group gap="xs">
|
||||
{r.counts.firing > 0 && (
|
||||
<Badge className={badgeClasses.healthErr}>
|
||||
firing ({r.counts.firing})
|
||||
</Badge>
|
||||
)}
|
||||
{r.counts.pending > 0 && (
|
||||
<Badge className={badgeClasses.healthWarn}>
|
||||
pending ({r.counts.pending})
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RuleDefinition rule={r} />
|
||||
{r.alerts.length > 0 && (
|
||||
<Table mt="lg">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Alert labels</Table.Th>
|
||||
<Table.Th>State</Table.Th>
|
||||
<Table.Th>Active Since</Table.Th>
|
||||
<Table.Th>Value</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{r.type === "alerting" &&
|
||||
r.alerts.map((a, 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 && (
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RuleDefinition rule={r.rule} />
|
||||
{r.rule.alerts.length > 0 && (
|
||||
<Table mt="lg">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Alert labels</Table.Th>
|
||||
<Table.Th>State</Table.Th>
|
||||
<Table.Th>Active Since</Table.Th>
|
||||
<Table.Th>Value</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{r.rule.type === "alerting" &&
|
||||
r.rule.alerts.map((a, k) => (
|
||||
<Fragment key={k}>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Table mt="md" mb="xl">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
{showAnnotations && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Table mt="md" mb="xl">
|
||||
<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.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</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 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),
|
||||
|
|
Loading…
Reference in a new issue