Alerting: Add alertmanager notifications panel (#37078)

* Add filter parsing to rule list filters

* Add unit tests for label parsing

* Make label operators an enum

* add example for parsing function

* Update labels operator regex

* Use tooltip for query syntax example

* refactor to use Matchers for filtering

* wip: initial alertmanager notifications panel

* Panel for alertmanager notificaitons

* add filtering for notifications list

* remove icon

* rename am notifications to alert groups

* naming fixes

* Feature toggle

* Add toggle for expand all

* add pluralize

* add action buttons

* test work in progress

* Tests for alert groups panel

* Add useEffect for expandAll prop change

* Set panel to alpha state

* Fix colors

* fix polling interval callback

Co-authored-by: Domas <domas.lapinskas@grafana.com>

Co-authored-by: Domas <domas.lapinskas@grafana.com>
This commit is contained in:
Nathan Rodman
2021-08-17 15:02:03 -07:00
committed by GitHub
parent 6579872122
commit b153bb6101
12 changed files with 454 additions and 31 deletions

View File

@@ -2,10 +2,11 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { LinkButton, useStyles2 } from '@grafana/ui';
import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from 'app/types/unified-alerting-dto';
import React, { FC } from 'react';
import { makeAMLink } from '../../utils/misc';
import { AnnotationDetailsField } from '../AnnotationDetailsField';
import { getMatcherQueryParams } from '../../utils/matchers';
interface AmNotificationsAlertDetailsProps {
alertManagerSourceName: string;
@@ -79,14 +80,3 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: ${theme.spacing(1, 0)};
`,
});
const getMatcherQueryParams = (labels: Labels) => {
return `matchers=${encodeURIComponent(
Object.entries(labels)
.filter(([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__')))
.map(([labelKey, labelValue]) => {
return `${labelKey}=${labelValue}`;
})
.join(',')
)}`;
};

View File

@@ -1,15 +1,15 @@
import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
import { getNotificationsTextColors } from '../../styles/notifications';
import pluralize from 'pluralize';
interface Props {
group: AlertmanagerGroup;
}
export const AmNotificationsGroupHeader = ({ group }: Props) => {
const styles = useStyles2(getStyles);
const textStyles = useStyles2(getNotificationsTextColors);
const total = group.alerts.length;
const countByStatus = group.alerts.reduce((statusObj, alert) => {
if (statusObj[alert.status.state]) {
@@ -21,11 +21,14 @@ export const AmNotificationsGroupHeader = ({ group }: Props) => {
}, {} as Record<AlertState, number>);
return (
<div className={styles.summary}>
{`${total} alerts: `}
<div>
{`${total} ${pluralize('alert', total)}: `}
{Object.entries(countByStatus).map(([state, count], index) => {
return (
<span key={`${JSON.stringify(group.labels)}-notifications-${index}`} className={styles[state as AlertState]}>
<span
key={`${JSON.stringify(group.labels)}-notifications-${index}`}
className={textStyles[state as AlertState]}
>
{index > 0 && ', '}
{`${count} ${state}`}
</span>
@@ -34,16 +37,3 @@ export const AmNotificationsGroupHeader = ({ group }: Props) => {
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
summary: css``,
[AlertState.Active]: css`
color: ${theme.colors.error.main};
`,
[AlertState.Suppressed]: css`
color: ${theme.colors.primary.main};
`,
[AlertState.Unprocessed]: css`
color: ${theme.colors.secondary.main};
`,
});

View File

@@ -0,0 +1,15 @@
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { AlertState } from 'app/plugins/datasource/alertmanager/types';
export const getNotificationsTextColors = (theme: GrafanaTheme2) => ({
[AlertState.Active]: css`
color: ${theme.colors.error.text};
`,
[AlertState.Suppressed]: css`
color: ${theme.colors.primary.text};
`,
[AlertState.Unprocessed]: css`
color: ${theme.colors.secondary.text};
`,
});

View File

@@ -1,4 +1,5 @@
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from '@grafana/data';
import { parseMatcher } from './alertmanager';
// parses comma separated matchers like "foo=bar,baz=~bad*" into SilenceMatcher[]
@@ -8,3 +9,14 @@ export function parseQueryParamMatchers(paramValue: string): Matcher[] {
.filter((x) => !!x.trim())
.map((x) => parseMatcher(x.trim()));
}
export const getMatcherQueryParams = (labels: Labels) => {
return `matchers=${encodeURIComponent(
Object.entries(labels)
.filter(([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__')))
.map(([labelKey, labelValue]) => {
return `${labelKey}=${labelValue}`;
})
.join(',')
)}`;
};

View File

@@ -66,6 +66,7 @@ import * as debugPanel from 'app/plugins/panel/debug/module';
import * as welcomeBanner from 'app/plugins/panel/welcome/module';
import * as nodeGraph from 'app/plugins/panel/nodeGraph/module';
import * as histogramPanel from 'app/plugins/panel/histogram/module';
import * as alertGroupsPanel from 'app/plugins/panel/alertGroups/module';
// Async loaded panels
const geomapPanel = async () => await import(/* webpackChunkName: "geomapPanel" */ 'app/plugins/panel/geomap/module');
@@ -119,6 +120,7 @@ const builtInPlugins: any = {
'app/plugins/panel/welcome/module': welcomeBanner,
'app/plugins/panel/nodeGraph/module': nodeGraph,
'app/plugins/panel/histogram/module': histogramPanel,
'app/plugins/panel/alertGroups/module': alertGroupsPanel,
};
export default builtInPlugins;

View File

@@ -0,0 +1,127 @@
import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types';
import React, { useState, useEffect } from 'react';
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data';
import { useStyles2, LinkButton } from '@grafana/ui';
import { css } from '@emotion/css';
import { AlertLabels } from 'app/features/alerting/unified/components/AlertLabels';
import { AmNotificationsGroupHeader } from 'app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader';
import { CollapseToggle } from 'app/features/alerting/unified/components/CollapseToggle';
import { getNotificationsTextColors } from 'app/features/alerting/unified/styles/notifications';
import { makeAMLink } from 'app/features/alerting/unified/utils/misc';
import { getMatcherQueryParams } from 'app/features/alerting/unified/utils/matchers';
type Props = {
alertManagerSourceName: string;
group: AlertmanagerGroup;
expandAll: boolean;
};
export const AlertGroup = ({ alertManagerSourceName, group, expandAll }: Props) => {
const [showAlerts, setShowAlerts] = useState(expandAll);
const styles = useStyles2(getStyles);
const textStyles = useStyles2(getNotificationsTextColors);
useEffect(() => setShowAlerts(expandAll), [expandAll]);
return (
<div className={styles.group} data-testid="alert-group">
{Object.keys(group.labels).length > 0 ? (
<AlertLabels labels={group.labels} />
) : (
<div className={styles.noGroupingText}>No grouping</div>
)}
<div className={styles.row}>
<CollapseToggle isCollapsed={!showAlerts} onToggle={() => setShowAlerts(!showAlerts)} />{' '}
<AmNotificationsGroupHeader group={group} />
</div>
{showAlerts && (
<div className={styles.alerts}>
{group.alerts.map((alert, index) => {
const state = alert.status.state.toUpperCase();
const interval = intervalToAbbreviatedDurationString({
start: new Date(alert.startsAt),
end: Date.now(),
});
return (
<div data-testid={'alert-group-alert'} className={styles.alert} key={`${alert.fingerprint}-${index}`}>
<div>
<span className={textStyles[alert.status.state]}>{state} </span>for {interval}
</div>
<div>
<AlertLabels labels={alert.labels} />
</div>
<div className={styles.actionsRow}>
{alert.status.state === AlertState.Suppressed && (
<LinkButton
href={`${makeAMLink(
'/alerting/silences',
alertManagerSourceName
)}&silenceIds=${alert.status.silencedBy.join(',')}`}
className={styles.button}
icon={'bell'}
size={'sm'}
>
Manage silences
</LinkButton>
)}
{alert.status.state === AlertState.Active && (
<LinkButton
href={`${makeAMLink('/alerting/silence/new', alertManagerSourceName)}&${getMatcherQueryParams(
alert.labels
)}`}
className={styles.button}
icon={'bell-slash'}
size={'sm'}
>
Silence
</LinkButton>
)}
{alert.generatorURL && (
<LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}>
See source
</LinkButton>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
noGroupingText: css`
height: ${theme.spacing(4)};
`,
group: css`
background-color: ${theme.colors.background.secondary};
margin: ${theme.spacing(0.5, 1, 0.5, 1)};
padding: ${theme.spacing(1)};
`,
row: css`
display: flex;
flex-direction: row;
align-items: center;
`,
alerts: css`
margin: ${theme.spacing(0, 2, 0, 4)};
`,
alert: css`
padding: ${theme.spacing(1, 0)};
& + & {
border-top: 1px solid ${theme.colors.border.medium};
}
`,
button: css`
& + & {
margin-left: ${theme.spacing(1)};
}
`,
actionsRow: css`
padding: ${theme.spacing(1, 0)};
`,
});

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { Provider } from 'react-redux';
import { AlertGroupsPanel } from './AlertGroupsPanel';
import { setDataSourceSrv } from '@grafana/runtime';
import { byTestId } from 'testing-library-selector';
import { configureStore } from 'app/store/configureStore';
import { AlertGroupPanelOptions } from './types';
import { getDefaultTimeRange, LoadingState, PanelProps, FieldConfigSource } from '@grafana/data';
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
import { fetchAlertGroups } from 'app/features/alerting/unified/api/alertmanager';
import {
mockAlertGroup,
mockAlertmanagerAlert,
mockDataSource,
MockDataSourceSrv,
} from 'app/features/alerting/unified/mocks';
import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
import { setDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { render, waitFor } from '@testing-library/react';
jest.mock('app/features/alerting/unified/api/alertmanager');
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
config: {
...jest.requireActual('@grafana/runtime').config,
buildInfo: {},
panels: {},
featureToggles: {
ngalert: true,
},
},
}));
const mocks = {
api: {
fetchAlertGroups: typeAsJestMock(fetchAlertGroups),
},
};
const dataSources = {
am: mockDataSource({
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
}),
};
const defaultOptions: AlertGroupPanelOptions = {
labels: '',
alertmanager: 'Alertmanager',
expandAll: false,
};
const defaultProps: PanelProps<AlertGroupPanelOptions> = {
data: { state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() },
id: 1,
timeRange: getDefaultTimeRange(),
timeZone: 'utc',
options: defaultOptions,
eventBus: {
subscribe: jest.fn(),
getStream: () =>
({
subscribe: jest.fn(),
} as any),
publish: jest.fn(),
removeAllListeners: jest.fn(),
newScopedBus: jest.fn(),
},
fieldConfig: ({} as unknown) as FieldConfigSource,
height: 400,
onChangeTimeRange: jest.fn(),
onFieldConfigChange: jest.fn(),
onOptionsChange: jest.fn(),
renderCounter: 1,
replaceVariables: jest.fn(),
title: 'Alert groups test',
transparent: false,
width: 320,
};
const renderPanel = (options: AlertGroupPanelOptions = defaultOptions) => {
const store = configureStore();
const dash: any = { id: 1, formatDate: (time: number) => new Date(time).toISOString() };
const dashSrv: any = { getCurrent: () => dash };
setDashboardSrv(dashSrv);
defaultProps.options = options;
const props = { ...defaultProps };
return render(
<Provider store={store}>
<AlertGroupsPanel {...props} />
</Provider>
);
};
const ui = {
group: byTestId('alert-group'),
alert: byTestId('alert-group-alert'),
};
describe('AlertGroupsPanel', () => {
beforeAll(() => {
mocks.api.fetchAlertGroups.mockImplementation(() => {
return Promise.resolve([
mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }),
mockAlertGroup(),
]);
});
});
beforeEach(() => {
setDataSourceSrv(new MockDataSourceSrv(dataSources));
});
it('renders the panel with the groups', async () => {
await renderPanel();
await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled());
const groups = ui.group.getAll();
expect(groups).toHaveLength(2);
expect(groups[0]).toHaveTextContent('No grouping');
expect(groups[1]).toHaveTextContent('severity=warningregion=US-Central');
const alerts = ui.alert.queryAll();
expect(alerts).toHaveLength(0);
});
it('renders panel with groups expanded', async () => {
await renderPanel({ labels: '', alertmanager: 'Alertmanager', expandAll: true });
await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled());
const alerts = ui.alert.queryAll();
expect(alerts).toHaveLength(3);
});
it('filters alerts by label filter', async () => {
await renderPanel({ labels: 'region=US-Central', alertmanager: 'Alertmanager', expandAll: true });
await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled());
const alerts = ui.alert.queryAll();
expect(alerts).toHaveLength(2);
});
});

View File

@@ -0,0 +1,66 @@
import React, { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { PanelProps } from '@grafana/data';
import { CustomScrollbar } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types';
import { fetchAlertGroupsAction } from 'app/features/alerting/unified/state/actions';
import { initialAsyncRequestState } from 'app/features/alerting/unified/utils/redux';
import { NOTIFICATIONS_POLL_INTERVAL_MS } from 'app/features/alerting/unified/utils/constants';
import { useUnifiedAlertingSelector } from 'app/features/alerting/unified/hooks/useUnifiedAlertingSelector';
import { AlertGroup } from './AlertGroup';
import { AlertGroupPanelOptions } from './types';
import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager';
import { useFilteredGroups } from './useFilteredGroups';
export const AlertGroupsPanel = (props: PanelProps<AlertGroupPanelOptions>) => {
const dispatch = useDispatch();
const isAlertingEnabled = config.featureToggles.ngalert;
const expandAll = props.options.expandAll;
const alertManagerSourceName = props.options.alertmanager;
const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups) || initialAsyncRequestState;
const results: AlertmanagerGroup[] = alertGroups[alertManagerSourceName || '']?.result || [];
const matchers: Matcher[] = props.options.labels ? parseMatchers(props.options.labels) : [];
const filteredResults = useFilteredGroups(results, matchers);
useEffect(() => {
function fetchNotifications() {
if (alertManagerSourceName) {
dispatch(fetchAlertGroupsAction(alertManagerSourceName));
}
}
fetchNotifications();
const interval = setInterval(fetchNotifications, NOTIFICATIONS_POLL_INTERVAL_MS);
return () => {
clearInterval(interval);
};
}, [dispatch, alertManagerSourceName]);
const hasResults = filteredResults.length > 0;
return (
<CustomScrollbar autoHeightMax="100%" autoHeightMin="100%">
{isAlertingEnabled && (
<div>
{hasResults &&
filteredResults.map((group) => {
return (
<AlertGroup
alertManagerSourceName={alertManagerSourceName}
key={JSON.stringify(group.labels)}
group={group}
expandAll={expandAll}
/>
);
})}
{!hasResults && 'No alerts'}
</div>
)}
</CustomScrollbar>
);
};

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { PanelPlugin } from '@grafana/data';
import { AlertGroupPanelOptions } from './types';
import { AlertGroupsPanel } from './AlertGroupsPanel';
import { AlertManagerPicker } from 'app/features/alerting/unified/components/AlertManagerPicker';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
export const plugin = new PanelPlugin<AlertGroupPanelOptions>(AlertGroupsPanel).setPanelOptions((builder) => {
return builder
.addCustomEditor({
name: 'Alertmanager',
path: 'alertmanager',
id: 'alertmanager',
defaultValue: GRAFANA_RULES_SOURCE_NAME,
category: ['Options'],
editor: function RenderAlertmanagerPicker(props) {
return (
<AlertManagerPicker
current={props.value}
onChange={(alertManagerSourceName) => {
return props.onChange(alertManagerSourceName);
}}
/>
);
},
})
.addBooleanSwitch({
name: 'Expand all by default',
path: 'expandAll',
defaultValue: false,
category: ['Options'],
})
.addTextInput({
description: 'Filter results by matching labels, ex: env=production,severity=~critical|warning',
name: 'Labels',
path: 'labels',
category: ['Filter'],
});
});

View File

@@ -0,0 +1,15 @@
{
"type": "panel",
"name": "Alert groups",
"id": "alertGroups",
"state": "alpha",
"skipDataQuery": true,
"info": {
"description": "Shows alertmanager alerts grouped by labels",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
}
}
}

View File

@@ -0,0 +1,5 @@
export interface AlertGroupPanelOptions {
labels: string;
alertmanager: string;
expandAll: boolean;
}

View File

@@ -0,0 +1,14 @@
import { labelsMatchMatchers } from 'app/features/alerting/unified/utils/alertmanager';
import { AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types';
import { useMemo } from 'react';
export const useFilteredGroups = (groups: AlertmanagerGroup[], matchers: Matcher[]): AlertmanagerGroup[] => {
return useMemo(() => {
return groups.filter((group) => {
return (
labelsMatchMatchers(group.labels, matchers) ||
group.alerts.some((alert) => labelsMatchMatchers(alert.labels, matchers))
);
});
}, [groups, matchers]);
};