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