mirror of https://github.com/grafana/grafana
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>pull/37991/head
parent
6579872122
commit
b153bb6101
@ -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}; |
||||||
|
`,
|
||||||
|
}); |
||||||
@ -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)}; |
||||||
|
`,
|
||||||
|
}); |
||||||
@ -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); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -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> |
||||||
|
); |
||||||
|
}; |
||||||
@ -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'], |
||||||
|
}); |
||||||
|
}); |
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
export interface AlertGroupPanelOptions { |
||||||
|
labels: string; |
||||||
|
alertmanager: string; |
||||||
|
expandAll: boolean; |
||||||
|
} |
||||||
@ -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]); |
||||||
|
}; |
||||||
Loading…
Reference in new issue