mirror of https://github.com/grafana/grafana
Alerting: Add alertmanager notifications tab (#35759)
* Add alertmanager notifications tab * Link to silences page from am alert * Include summary for alertmanager group * Fix colors for am state * Add horizontal dividing line * PR feedback * Add basic unit test for alert notificaitons * Rename Notificaitons component file * Polling interval to groups * Add alertmanager notifications tab * Link to silences page from am alert * Include summary for alertmanager group * PR feedback * Add basic unit test for alert notificaitons * Rename Notificaitons component file * Alerting: make alertmanager notifications view responsive (#36067) * refac DynamicTableWithGuidelines * more responsiveness fixes * Add more to tests * Add loading and alert state for notifications Co-authored-by: Domas <domas.lapinskas@grafana.com>pull/36536/head^2
parent
d9e500b654
commit
a0dac9c6d9
@ -0,0 +1,84 @@ |
||||
import React from 'react'; |
||||
import { locationService, setDataSourceSrv } from '@grafana/runtime'; |
||||
import { render, waitFor } from '@testing-library/react'; |
||||
import { Provider } from 'react-redux'; |
||||
import { Router } from 'react-router-dom'; |
||||
import { fetchAlertGroups } from './api/alertmanager'; |
||||
import { byTestId, byText } from 'testing-library-selector'; |
||||
import { configureStore } from 'app/store/configureStore'; |
||||
import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; |
||||
import AmNotifications from './AmNotifications'; |
||||
import { mockAlertGroup, mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv } from './mocks'; |
||||
import { DataSourceType } from './utils/datasource'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
jest.mock('./api/alertmanager'); |
||||
|
||||
const mocks = { |
||||
api: { |
||||
fetchAlertGroups: typeAsJestMock(fetchAlertGroups), |
||||
}, |
||||
}; |
||||
|
||||
const renderAmNotifications = () => { |
||||
const store = configureStore(); |
||||
|
||||
return render( |
||||
<Provider store={store}> |
||||
<Router history={locationService.getHistory()}> |
||||
<AmNotifications /> |
||||
</Router> |
||||
</Provider> |
||||
); |
||||
}; |
||||
|
||||
const dataSources = { |
||||
am: mockDataSource({ |
||||
name: 'Alert Manager', |
||||
type: DataSourceType.Alertmanager, |
||||
}), |
||||
}; |
||||
|
||||
const ui = { |
||||
group: byTestId('notifications-group'), |
||||
groupCollapseToggle: byTestId('notifications-group-collapse-toggle'), |
||||
notificationsTable: byTestId('notifications-table'), |
||||
row: byTestId('row'), |
||||
collapseToggle: byTestId('collapse-toggle'), |
||||
silenceButton: byText('Silence'), |
||||
sourceButton: byText('See source'), |
||||
}; |
||||
|
||||
describe('AmNotifications', () => { |
||||
beforeAll(() => { |
||||
mocks.api.fetchAlertGroups.mockImplementation(() => { |
||||
return Promise.resolve([ |
||||
mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }), |
||||
mockAlertGroup(), |
||||
]); |
||||
}); |
||||
}); |
||||
|
||||
beforeEach(() => { |
||||
setDataSourceSrv(new MockDataSourceSrv(dataSources)); |
||||
}); |
||||
|
||||
it('loads and shows groups', async () => { |
||||
await renderAmNotifications(); |
||||
|
||||
await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled()); |
||||
|
||||
const groups = await ui.group.getAll(); |
||||
|
||||
expect(groups).toHaveLength(2); |
||||
expect(groups[0]).toHaveTextContent('No grouping'); |
||||
expect(groups[1]).toHaveTextContent('severity=warningregion=US-Central'); |
||||
|
||||
userEvent.click(ui.groupCollapseToggle.get(groups[0])); |
||||
expect(ui.notificationsTable.get()).toBeDefined(); |
||||
|
||||
userEvent.click(ui.collapseToggle.get(ui.notificationsTable.get())); |
||||
expect(ui.silenceButton.get(ui.notificationsTable.get())).toBeDefined(); |
||||
expect(ui.sourceButton.get(ui.notificationsTable.get())).toBeDefined(); |
||||
}); |
||||
}); |
@ -0,0 +1,62 @@ |
||||
import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; |
||||
import React, { useEffect } from 'react'; |
||||
|
||||
import { useDispatch } from 'react-redux'; |
||||
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper'; |
||||
import { AlertManagerPicker } from './components/AlertManagerPicker'; |
||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; |
||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; |
||||
import { fetchAlertGroupsAction } from './state/actions'; |
||||
import { initialAsyncRequestState } from './utils/redux'; |
||||
|
||||
import { AmNotificationsGroup } from './components/amnotifications/AmNotificationsGroup'; |
||||
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants'; |
||||
import { Alert, LoadingPlaceholder } from '../../../../../packages/grafana-ui/src'; |
||||
|
||||
const AlertManagerNotifications = () => { |
||||
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); |
||||
const dispatch = useDispatch(); |
||||
|
||||
const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups) || initialAsyncRequestState; |
||||
const loading = alertGroups[alertManagerSourceName || '']?.loading; |
||||
const error = alertGroups[alertManagerSourceName || '']?.error; |
||||
const results: AlertmanagerGroup[] = alertGroups[alertManagerSourceName || '']?.result || []; |
||||
|
||||
useEffect(() => { |
||||
function fetchNotifications() { |
||||
if (alertManagerSourceName) { |
||||
dispatch(fetchAlertGroupsAction(alertManagerSourceName)); |
||||
} |
||||
} |
||||
fetchNotifications(); |
||||
const interval = setInterval(() => fetchNotifications, NOTIFICATIONS_POLL_INTERVAL_MS); |
||||
return () => { |
||||
clearInterval(interval); |
||||
}; |
||||
}, [dispatch, alertManagerSourceName]); |
||||
|
||||
return ( |
||||
<AlertingPageWrapper pageId="notifications"> |
||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} /> |
||||
{loading && <LoadingPlaceholder text="Loading notifications" />} |
||||
{error && !loading && ( |
||||
<Alert title={'Error loading notifications'} severity={'error'}> |
||||
{error.message || 'Unknown error'} |
||||
</Alert> |
||||
)} |
||||
{results && |
||||
results.map((group, index) => { |
||||
return ( |
||||
<AmNotificationsGroup |
||||
alertManagerSourceName={alertManagerSourceName || ''} |
||||
key={`${JSON.stringify(group.labels)}-group-${index}`} |
||||
group={group} |
||||
/> |
||||
); |
||||
})} |
||||
</AlertingPageWrapper> |
||||
); |
||||
}; |
||||
|
||||
export default AlertManagerNotifications; |
@ -0,0 +1,76 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import React from 'react'; |
||||
import { DynamicTable, DynamicTableProps } from './DynamicTable'; |
||||
|
||||
export type DynamicTableWithGuidelinesProps<T> = Omit<DynamicTableProps<T>, 'renderPrefixHeader, renderPrefixCell'>; |
||||
|
||||
// DynamicTable, but renders visual guidelines on the left, for larger screen widths
|
||||
export const DynamicTableWithGuidelines = <T extends object>({ |
||||
renderExpandedContent, |
||||
...props |
||||
}: DynamicTableWithGuidelinesProps<T>) => { |
||||
const styles = useStyles2(getStyles); |
||||
return ( |
||||
<DynamicTable |
||||
renderExpandedContent={ |
||||
renderExpandedContent |
||||
? (item, index, items) => ( |
||||
<> |
||||
{!(index === items.length - 1) && <div className={cx(styles.contentGuideline, styles.guideline)} />} |
||||
{renderExpandedContent(item, index, items)} |
||||
</> |
||||
) |
||||
: undefined |
||||
} |
||||
renderPrefixHeader={() => ( |
||||
<div className={styles.relative}> |
||||
<div className={cx(styles.headerGuideline, styles.guideline)} /> |
||||
</div> |
||||
)} |
||||
renderPrefixCell={(_, index, items) => ( |
||||
<div className={styles.relative}> |
||||
<div className={cx(styles.topGuideline, styles.guideline)} /> |
||||
{!(index === items.length - 1) && <div className={cx(styles.bottomGuideline, styles.guideline)} />} |
||||
</div> |
||||
)} |
||||
{...props} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({ |
||||
relative: css` |
||||
position: relative; |
||||
height: 100%; |
||||
`,
|
||||
guideline: css` |
||||
left: -19px; |
||||
border-left: 1px solid ${theme.colors.border.medium}; |
||||
position: absolute; |
||||
|
||||
${theme.breakpoints.down('md')} { |
||||
display: none; |
||||
} |
||||
`,
|
||||
topGuideline: css` |
||||
width: 18px; |
||||
border-bottom: 1px solid ${theme.colors.border.medium}; |
||||
top: 0; |
||||
bottom: 50%; |
||||
`,
|
||||
bottomGuideline: css` |
||||
top: 50%; |
||||
bottom: 0; |
||||
`,
|
||||
contentGuideline: css` |
||||
top: 0; |
||||
bottom: 0; |
||||
left: -49px !important; |
||||
`,
|
||||
headerGuideline: css` |
||||
top: -25px; |
||||
bottom: 0; |
||||
`,
|
||||
}); |
@ -0,0 +1,92 @@ |
||||
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'; |
||||
|
||||
interface AmNotificationsAlertDetailsProps { |
||||
alertManagerSourceName: string; |
||||
alert: AlertmanagerAlert; |
||||
} |
||||
|
||||
export const AmNotificationsAlertDetails: FC<AmNotificationsAlertDetailsProps> = ({ |
||||
alert, |
||||
alertManagerSourceName, |
||||
}) => { |
||||
const styles = useStyles2(getStyles); |
||||
return ( |
||||
<> |
||||
<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> |
||||
{Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => ( |
||||
<AnnotationDetailsField key={annotationKey} annotationKey={annotationKey} value={annotationValue} /> |
||||
))} |
||||
<div className={styles.receivers}> |
||||
Receivers:{' '} |
||||
{alert.receivers |
||||
.map(({ name }) => name) |
||||
.filter((name) => !!name) |
||||
.join(', ')} |
||||
</div> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
button: css` |
||||
& + & { |
||||
margin-left: ${theme.spacing(1)}; |
||||
} |
||||
`,
|
||||
actionsRow: css` |
||||
padding: ${theme.spacing(2, 0)} !important; |
||||
border-bottom: 1px solid ${theme.colors.border.medium}; |
||||
`,
|
||||
receivers: css` |
||||
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(',') |
||||
)}`;
|
||||
}; |
@ -0,0 +1,91 @@ |
||||
import { AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types'; |
||||
import React, { useMemo } from 'react'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data'; |
||||
import { css } from '@emotion/css'; |
||||
import { DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; |
||||
import { AmAlertStateTag } from '../silences/AmAlertStateTag'; |
||||
import { AlertLabels } from '../AlertLabels'; |
||||
import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines'; |
||||
import { AmNotificationsAlertDetails } from './AmNotificationsAlertDetails'; |
||||
|
||||
interface Props { |
||||
alerts: AlertmanagerAlert[]; |
||||
alertManagerSourceName: string; |
||||
} |
||||
|
||||
type AmNotificationsAlertsTableColumnProps = DynamicTableColumnProps<AlertmanagerAlert>; |
||||
type AmNotificationsAlertsTableItemProps = DynamicTableItemProps<AlertmanagerAlert>; |
||||
|
||||
export const AmNotificationsAlertsTable = ({ alerts, alertManagerSourceName }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const columns = useMemo( |
||||
(): AmNotificationsAlertsTableColumnProps[] => [ |
||||
{ |
||||
id: 'state', |
||||
label: 'State', |
||||
// eslint-disable-next-line react/display-name
|
||||
renderCell: ({ data: alert }) => ( |
||||
<> |
||||
<AmAlertStateTag state={alert.status.state} /> |
||||
<span className={styles.duration}> |
||||
for{' '} |
||||
{intervalToAbbreviatedDurationString({ |
||||
start: new Date(alert.startsAt), |
||||
end: new Date(alert.endsAt), |
||||
})} |
||||
</span> |
||||
</> |
||||
), |
||||
size: '190px', |
||||
}, |
||||
{ |
||||
id: 'labels', |
||||
label: 'Labels', |
||||
// eslint-disable-next-line react/display-name
|
||||
renderCell: ({ data: { labels } }) => <AlertLabels className={styles.labels} labels={labels} />, |
||||
size: 1, |
||||
}, |
||||
], |
||||
[styles] |
||||
); |
||||
|
||||
const items = useMemo( |
||||
(): AmNotificationsAlertsTableItemProps[] => |
||||
alerts.map((alert) => ({ |
||||
id: alert.fingerprint, |
||||
data: alert, |
||||
})), |
||||
[alerts] |
||||
); |
||||
|
||||
return ( |
||||
<div className={styles.tableWrapper} data-testid="notifications-table"> |
||||
<DynamicTableWithGuidelines |
||||
cols={columns} |
||||
items={items} |
||||
isExpandable={true} |
||||
renderExpandedContent={({ data: alert }) => ( |
||||
<AmNotificationsAlertDetails alert={alert} alertManagerSourceName={alertManagerSourceName} /> |
||||
)} |
||||
/> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
tableWrapper: css` |
||||
margin-top: ${theme.spacing(3)}; |
||||
${theme.breakpoints.up('md')} { |
||||
margin-left: ${theme.spacing(4.5)}; |
||||
} |
||||
`,
|
||||
duration: css` |
||||
margin-left: ${theme.spacing(1)}; |
||||
font-size: ${theme.typography.bodySmall.fontSize}; |
||||
`,
|
||||
labels: css` |
||||
padding-bottom: 0; |
||||
`,
|
||||
}); |
@ -0,0 +1,82 @@ |
||||
import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types'; |
||||
import React, { useState } from 'react'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import { css } from '@emotion/css'; |
||||
import { AlertLabels } from '../AlertLabels'; |
||||
import { AmNotificationsAlertsTable } from './AmNotificationsAlertsTable'; |
||||
import { CollapseToggle } from '../CollapseToggle'; |
||||
import { AmNotificationsGroupHeader } from './AmNotificationsGroupHeader'; |
||||
|
||||
interface Props { |
||||
group: AlertmanagerGroup; |
||||
alertManagerSourceName: string; |
||||
} |
||||
|
||||
export const AmNotificationsGroup = ({ alertManagerSourceName, group }: Props) => { |
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(true); |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<div className={styles.header}> |
||||
<div className={styles.group} data-testid="notifications-group"> |
||||
<CollapseToggle |
||||
isCollapsed={isCollapsed} |
||||
onToggle={() => setIsCollapsed(!isCollapsed)} |
||||
data-testid="notifications-group-collapse-toggle" |
||||
/> |
||||
{Object.keys(group.labels).length ? ( |
||||
<AlertLabels className={styles.headerLabels} labels={group.labels} /> |
||||
) : ( |
||||
<span>No grouping</span> |
||||
)} |
||||
</div> |
||||
<AmNotificationsGroupHeader group={group} /> |
||||
</div> |
||||
{!isCollapsed && ( |
||||
<AmNotificationsAlertsTable alertManagerSourceName={alertManagerSourceName} alerts={group.alerts} /> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
wrapper: css` |
||||
& + & { |
||||
margin-top: ${theme.spacing(2)}; |
||||
} |
||||
`,
|
||||
headerLabels: css` |
||||
padding-bottom: 0 !important; |
||||
margin-bottom: -${theme.spacing(0.5)}; |
||||
`,
|
||||
header: css` |
||||
display: flex; |
||||
flex-direction: row; |
||||
flex-wrap: wrap; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
padding: ${theme.spacing(1, 1, 1, 0)}; |
||||
background-color: ${theme.colors.background.secondary}; |
||||
width: 100%; |
||||
`,
|
||||
group: css` |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
`,
|
||||
summary: css``, |
||||
spanElement: css` |
||||
margin-left: ${theme.spacing(0.5)}; |
||||
`,
|
||||
[AlertState.Active]: css` |
||||
color: ${theme.colors.error.main}; |
||||
`,
|
||||
[AlertState.Suppressed]: css` |
||||
color: ${theme.colors.primary.main}; |
||||
`,
|
||||
[AlertState.Unprocessed]: css` |
||||
color: ${theme.colors.secondary.main}; |
||||
`,
|
||||
}); |
@ -0,0 +1,49 @@ |
||||
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'; |
||||
|
||||
interface Props { |
||||
group: AlertmanagerGroup; |
||||
} |
||||
|
||||
export const AmNotificationsGroupHeader = ({ group }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
const total = group.alerts.length; |
||||
const countByStatus = group.alerts.reduce((statusObj, alert) => { |
||||
if (statusObj[alert.status.state]) { |
||||
statusObj[alert.status.state] += 1; |
||||
} else { |
||||
statusObj[alert.status.state] = 1; |
||||
} |
||||
return statusObj; |
||||
}, {} as Record<AlertState, number>); |
||||
|
||||
return ( |
||||
<div className={styles.summary}> |
||||
{`${total} alerts: `} |
||||
{Object.entries(countByStatus).map(([state, count], index) => { |
||||
return ( |
||||
<span key={`${JSON.stringify(group.labels)}-notifications-${index}`} className={styles[state as AlertState]}> |
||||
{index > 0 && ', '} |
||||
{`${count} ${state}`} |
||||
</span> |
||||
); |
||||
})} |
||||
</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}; |
||||
`,
|
||||
}); |
Loading…
Reference in new issue