diff --git a/public/app/features/alerting/unified/Receivers.test.tsx b/public/app/features/alerting/unified/Receivers.test.tsx index 9bc2b11fb20..fa01bfc8826 100644 --- a/public/app/features/alerting/unified/Receivers.test.tsx +++ b/public/app/features/alerting/unified/Receivers.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, within, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; @@ -12,12 +12,12 @@ import { contextSrv } from 'app/core/services/context_srv'; import store from 'app/core/store'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; import { configureStore } from 'app/store/configureStore'; -import { AccessControlAction } from 'app/types'; +import { AccessControlAction, ContactPointsState } from 'app/types'; import Receivers from './Receivers'; import { updateAlertManagerConfig, fetchAlertManagerConfig, fetchStatus, testReceivers } from './api/alertmanager'; import { discoverAlertmanagerFeatures } from './api/buildInfo'; -import { fetchNotifiers } from './api/grafana'; +import { fetchNotifiers, fetchContactPointsState } from './api/grafana'; import { mockDataSource, MockDataSourceSrv, @@ -46,6 +46,7 @@ const mocks = { fetchNotifiers: jest.mocked(fetchNotifiers), testReceivers: jest.mocked(testReceivers), discoverAlertmanagerFeatures: jest.mocked(discoverAlertmanagerFeatures), + fetchReceivers: jest.mocked(fetchContactPointsState), }, contextSrv: jest.mocked(contextSrv), }; @@ -95,12 +96,15 @@ const ui = { testContactPoint: byRole('button', { name: /send test notification/i }), cancelButton: byTestId('cancel-button'), - receiversTable: byTestId('receivers-table'), + receiversTable: byTestId('dynamic-table'), templatesTable: byTestId('templates-table'), alertManagerPicker: byTestId('alertmanager-picker'), channelFormContainer: byTestId('item-container'), + notificationError: byTestId('receivers-notification-error'), + contactPointsCollapseToggle: byTestId('collapse-toggle'), + inputs: { name: byPlaceholderText('Name'), email: { @@ -133,6 +137,8 @@ describe('Receivers', () => { mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock); mocks.api.discoverAlertmanagerFeatures.mockResolvedValue({ lazyConfigInit: false }); + const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount: 0 }; + mocks.api.fetchReceivers.mockResolvedValue(emptyContactPointsState); setDataSourceSrv(new MockDataSourceSrv(dataSources)); mocks.contextSrv.isEditor = true; store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); @@ -151,21 +157,21 @@ describe('Receivers', () => { mocks.contextSrv.hasAccess.mockImplementation(() => true); }); - it('Template and receiver tables are rendered, alertmanager can be selected', async () => { + it('Template and receiver tables are rendered, alertmanager can be selected, no notification errors', async () => { mocks.api.fetchConfig.mockImplementation((name) => Promise.resolve(name === GRAFANA_RULES_SOURCE_NAME ? someGrafanaAlertManagerConfig : someCloudAlertManagerConfig) ); await renderReceivers(); // check that by default grafana templates & receivers are fetched rendered in appropriate tables - let receiversTable = await ui.receiversTable.find(); + await ui.receiversTable.find(); let templatesTable = await ui.templatesTable.find(); let templateRows = templatesTable.querySelectorAll('tbody tr'); expect(templateRows).toHaveLength(3); expect(templateRows[0]).toHaveTextContent('first template'); expect(templateRows[1]).toHaveTextContent('second template'); expect(templateRows[2]).toHaveTextContent('third template'); - let receiverRows = receiversTable.querySelectorAll('tbody tr'); + let receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(receiverRows[0]).toHaveTextContent('default'); expect(receiverRows[1]).toHaveTextContent('critical'); expect(receiverRows).toHaveLength(2); @@ -181,15 +187,18 @@ describe('Receivers', () => { expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(2); expect(mocks.api.fetchConfig).toHaveBeenLastCalledWith('CloudManager'); - receiversTable = await ui.receiversTable.find(); + await ui.receiversTable.find(); templatesTable = await ui.templatesTable.find(); templateRows = templatesTable.querySelectorAll('tbody tr'); expect(templateRows[0]).toHaveTextContent('foo template'); expect(templateRows).toHaveLength(1); - receiverRows = receiversTable.querySelectorAll('tbody tr'); + receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(receiverRows[0]).toHaveTextContent('cloud-receiver'); expect(receiverRows).toHaveLength(1); expect(locationService.getSearchObject()[ALERTMANAGER_NAME_QUERY_KEY]).toEqual('CloudManager'); + + //should not render any notification error + expect(ui.notificationError.query()).not.toBeInTheDocument(); }); it('Grafana receiver can be tested', async () => { @@ -329,8 +338,8 @@ describe('Receivers', () => { await renderReceivers('CloudManager'); // click edit button for the receiver - const receiversTable = await ui.receiversTable.find(); - const receiverRows = receiversTable.querySelectorAll('tbody tr'); + await ui.receiversTable.find(); + const receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(receiverRows[0]).toHaveTextContent('cloud-receiver'); await userEvent.click(byTestId('edit').get(receiverRows[0])); @@ -424,12 +433,12 @@ describe('Receivers', () => { }); await renderReceivers(dataSources.promAlertManager.name); - const receiversTable = await ui.receiversTable.find(); + await ui.receiversTable.find(); // there's no templates table for vanilla prom, API does not return templates expect(ui.templatesTable.query()).not.toBeInTheDocument(); // click view button on the receiver - const receiverRows = receiversTable.querySelectorAll('tbody tr'); + const receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(receiverRows[0]).toHaveTextContent('cloud-receiver'); expect(byTestId('edit').query(receiverRows[0])).not.toBeInTheDocument(); await userEvent.click(byTestId('view').get(receiverRows[0])); @@ -464,8 +473,8 @@ describe('Receivers', () => { await renderReceivers('CloudManager'); // check that receiver from the default config is represented - const receiversTable = await ui.receiversTable.find(); - const receiverRows = receiversTable.querySelectorAll('tbody tr'); + await ui.receiversTable.find(); + const receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(receiverRows[0]).toHaveTextContent('default-email'); // check that both config and status endpoints were called @@ -488,4 +497,96 @@ describe('Receivers', () => { expect(receiversTable).toBeInTheDocument(); expect(ui.newContactPointButton.get()).toBeInTheDocument(); }); + describe('Contact points state', () => { + it('Should render error notifications when there are some points state ', async () => { + mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig); + mocks.api.updateConfig.mockResolvedValue(); + + const receiversMock: ContactPointsState = { + receivers: { + default: { + active: true, + notifiers: { + email: [ + { + lastNotifyAttemptError: + 'establish connection to server: dial tcp: lookup smtp.example.org on 8.8.8.8:53: no such host', + lastNotifyAttempt: '2022-09-19T15:34:40.696Z', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[0]', + }, + ], + }, + errorCount: 1, + }, + critical: { + active: true, + notifiers: { + slack: [ + { + lastNotifyAttempt: '2022-09-19T15:34:40.696Z', + lastNotifyAttemptDuration: '117.2455ms', + name: 'slack[0]', + }, + ], + pagerduty: [ + { + lastNotifyAttempt: '2022-09-19T15:34:40.696Z', + lastNotifyAttemptDuration: '117.2455ms', + name: 'pagerduty', + }, + ], + }, + errorCount: 0, + }, + }, + errorCount: 1, + }; + + mocks.api.fetchReceivers.mockResolvedValue(receiversMock); + await renderReceivers(); + + // + await ui.receiversTable.find(); + //should render notification error + expect(ui.notificationError.query()).toBeInTheDocument(); + expect(ui.notificationError.get()).toHaveTextContent('1 error with contact points'); + + const receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); + expect(receiverRows[0]).toHaveTextContent('1 error'); + expect(receiverRows[1]).not.toHaveTextContent('error'); + expect(receiverRows[1]).toHaveTextContent('OK'); + + //should show error in contact points when expanding + // expand contact point detail for default 2 emails - 2 errors + await userEvent.click(ui.contactPointsCollapseToggle.get(receiverRows[0])); + const defaultDetailTable = screen.getAllByTestId('dynamic-table')[1]; + expect(byText('1 error').getAll(defaultDetailTable)).toHaveLength(1); + + // expand contact point detail for slack and pagerduty - 0 errors + await userEvent.click(ui.contactPointsCollapseToggle.get(receiverRows[1])); + const criticalDetailTable = screen.getAllByTestId('dynamic-table')[2]; + expect(byText('1 error').query(criticalDetailTable)).toBeNull(); + expect(byText('OK').getAll(criticalDetailTable)).toHaveLength(2); + }); + + it('Should not render error notifications when fetchContactPointsState raises 404 error ', async () => { + mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig); + mocks.api.updateConfig.mockResolvedValue(); + + mocks.api.fetchReceivers.mockRejectedValue({ status: 404 }); + await renderReceivers(); + + await ui.receiversTable.find(); + //should not render notification error + expect(ui.notificationError.query()).not.toBeInTheDocument(); + //contact points are not expandable + expect(ui.contactPointsCollapseToggle.query()).not.toBeInTheDocument(); + //should render receivers, only one dynamic table + let receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); + expect(receiverRows[0]).toHaveTextContent('default'); + expect(receiverRows[1]).toHaveTextContent('critical'); + expect(receiverRows).toHaveLength(2); + }); + }); }); diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index e7a431323e1..b58204efec8 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -1,8 +1,10 @@ +import { css } from '@emotion/css'; +import pluralize from 'pluralize'; import React, { FC, useEffect } from 'react'; import { Redirect, Route, RouteChildrenProps, Switch, useLocation, useParams } from 'react-router-dom'; -import { NavModelItem } from '@grafana/data'; -import { Alert, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui'; +import { NavModelItem, GrafanaTheme2 } from '@grafana/data'; +import { Alert, LoadingPlaceholder, withErrorBoundary, useStyles2, Icon, Stack } from '@grafana/ui'; import { useDispatch } from 'app/types'; import { AlertManagerPicker } from './components/AlertManagerPicker'; @@ -17,14 +19,42 @@ import { ReceiversAndTemplatesView } from './components/receivers/ReceiversAndTe import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchAlertManagerConfigAction, fetchGrafanaNotifiersAction } from './state/actions'; +import { + fetchAlertManagerConfigAction, + fetchContactPointsStateAction, + fetchGrafanaNotifiersAction, +} from './state/actions'; +import { CONTACT_POINTS_STATE_INTERVAL_MS } from './utils/constants'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { initialAsyncRequestState } from './utils/redux'; +export interface NotificationErrorProps { + errorCount: number; +} + +function NotificationError({ errorCount }: NotificationErrorProps) { + const styles = useStyles2(getStyles); + + return ( +
+ + + +
+ {`${errorCount} ${pluralize('error', errorCount)} with contact points`} +
+
+
{'Some alert notifications might not be delivered'}
+
+
+ ); +} + const Receivers: FC = () => { const alertManagers = useAlertManagersByPermission('notification'); const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); const dispatch = useDispatch(); + const styles = useStyles2(getStyles); type PageType = 'receivers' | 'templates' | 'global-config'; @@ -33,15 +63,21 @@ const Receivers: FC = () => { const isRoot = location.pathname.endsWith('/alerting/notifications'); const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs); + const contactPointsStateRequest = useUnifiedAlertingSelector((state) => state.contactPointsState); const { result: config, loading, error, } = (alertManagerSourceName && configRequests[alertManagerSourceName]) || initialAsyncRequestState; + + const { result: contactPointsState } = + (alertManagerSourceName && contactPointsStateRequest) || initialAsyncRequestState; + const receiverTypes = useUnifiedAlertingSelector((state) => state.grafanaNotifiers); const shouldLoadConfig = isRoot || !config; + const shouldRenderNotificationStatus = isRoot; useEffect(() => { if (alertManagerSourceName && shouldLoadConfig) { @@ -58,6 +94,21 @@ const Receivers: FC = () => { } }, [alertManagerSourceName, dispatch, receiverTypes]); + useEffect(() => { + function fetchContactPointStates() { + if (shouldRenderNotificationStatus && alertManagerSourceName) { + dispatch(fetchContactPointsStateAction(alertManagerSourceName)); + } + } + fetchContactPointStates(); + const interval = setInterval(fetchContactPointStates, CONTACT_POINTS_STATE_INTERVAL_MS); + return () => { + clearInterval(interval); + }; + }, [shouldRenderNotificationStatus, alertManagerSourceName, dispatch]); + + const integrationsErrorCount = contactPointsState?.errorCount ?? 0; + const disableAmSelect = !isRoot; let pageNav: NavModelItem | undefined; @@ -93,12 +144,17 @@ const Receivers: FC = () => { return ( - +
+ + {shouldRenderNotificationStatus && integrationsErrorCount > 0 && ( + + )} +
{error && !loading && ( {error.message || 'Unknown error.'} @@ -148,3 +204,16 @@ const Receivers: FC = () => { }; export default withErrorBoundary(Receivers, { style: 'page' }); + +const getStyles = (theme: GrafanaTheme2) => ({ + warning: css` + color: ${theme.colors.warning.text}; + `, + countMessage: css` + padding-left: 10px; + `, + headingContainer: css` + display: flex; + justify-content: space-between; + `, +}); diff --git a/public/app/features/alerting/unified/api/grafana.test.ts b/public/app/features/alerting/unified/api/grafana.test.ts new file mode 100644 index 00000000000..b83282258e5 --- /dev/null +++ b/public/app/features/alerting/unified/api/grafana.test.ts @@ -0,0 +1,152 @@ +import { ReceiversStateDTO } from 'app/types'; + +import { contactPointsStateDtoToModel, getIntegrationType, parseIntegrationName } from './grafana'; + +describe('parseIntegrationName method', () => { + it('should return the integration name and index string when it is a valid type name with [{number}] ', () => { + const { type, index } = parseIntegrationName('coolIntegration[1]'); + expect(type).toBe('coolIntegration'); + expect(index).toBe('[1]'); + }); + it('should return the integration name when it is a valid type name without [{number}] ', () => { + const { type, index } = parseIntegrationName('coolIntegration'); + expect(type).toBe('coolIntegration'); + expect(index).toBe(undefined); + }); + it('should return name as it is and index as undefined when it is a invalid index format ', () => { + const { type, index } = parseIntegrationName('coolIntegration[345vadkfjgh'); + expect(type).toBe('coolIntegration[345vadkfjgh'); + expect(index).toBe(undefined); + }); +}); + +describe('getIntegrationType method', () => { + it('should return the integration name when it is a valid type name with [{number}] ', () => { + const name = getIntegrationType('coolIntegration[1]'); + expect(name).toBe('coolIntegration'); + + const name2 = getIntegrationType('coolIntegration[6767]'); + expect(name2).toBe('coolIntegration'); + }); + it('should return the integration name when it is a valid type name without [{number}] ', () => { + const name = getIntegrationType('coolIntegration'); + expect(name).toBe('coolIntegration'); + }); + it('should return name as it is when it is a invalid index format ', () => { + const name = getIntegrationType('coolIntegration[345vadkfjgh'); + expect(name).toBe('coolIntegration[345vadkfjgh'); + }); +}); + +describe('contactPointsStateDtoToModel method', () => { + it('should return the expected object', () => { + const response = [ + { + active: true, + integrations: [ + { + lastNotifyAttemptError: + 'establish connection to server: dial tcp: lookup smtp.example.org on 8.8.8.8:53: no such host', + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[0]', + }, + { + lastNotifyAttemptError: + 'establish connection to server: dial tcp: lookup smtp.example.org on 8.8.8.8:53: no such host', + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[1]', + }, + { + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[2]', + }, + { + lastNotifyAttemptError: + 'establish connection to server: dial tcp: lookup smtp.example.org on 8.8.8.8:53: no such host', + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'webhook[0]', + }, + ], + name: 'contact point 1', + }, + { + active: true, + integrations: [ + { + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[0]', + }, + ], + name: 'contact point 2', + }, + ]; + expect(contactPointsStateDtoToModel(response)).toStrictEqual({ + errorCount: 3, + receivers: { + 'contact point 1': { + active: true, + errorCount: 3, + notifiers: { + email: [ + { + lastNotifyAttemptError: + 'establish connection to server: dial tcp: lookup smtp.example.org on 8.8.8.8:53: no such host', + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[0]', + }, + { + lastNotifyAttemptError: + 'establish connection to server: dial tcp: lookup smtp.example.org on 8.8.8.8:53: no such host', + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[1]', + }, + { + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[2]', + }, + ], + webhook: [ + { + lastNotifyAttemptError: + 'establish connection to server: dial tcp: lookup smtp.example.org on 8.8.8.8:53: no such host', + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'webhook[0]', + }, + ], + }, + }, + 'contact point 2': { + active: true, + errorCount: 0, + notifiers: { + email: [ + { + lastNotifyAttempt: '2022-07-08 17:42:44.998893 +0000 UTC', + lastNotifyAttemptDuration: '117.2455ms', + name: 'email[0]', + }, + ], + }, + }, + }, + }); + }); + + //this test will be updated depending on how BE response is implemented when there is no state available for this AM + it('should return the expected object if response is an empty array (no state available for this AM)', () => { + const response: ReceiversStateDTO[] = []; + expect(contactPointsStateDtoToModel(response)).toStrictEqual({ + errorCount: 0, + receivers: {}, + }); + }); +}); diff --git a/public/app/features/alerting/unified/api/grafana.ts b/public/app/features/alerting/unified/api/grafana.ts index dcca81c1966..d4edba6afbe 100644 --- a/public/app/features/alerting/unified/api/grafana.ts +++ b/public/app/features/alerting/unified/api/grafana.ts @@ -1,6 +1,78 @@ +import { lastValueFrom } from 'rxjs'; + import { getBackendSrv } from '@grafana/runtime'; -import { NotifierDTO } from 'app/types'; +import { ContactPointsState, NotifierDTO, ReceiversStateDTO, ReceiverState } from 'app/types'; + +import { getDatasourceAPIUid } from '../utils/datasource'; export function fetchNotifiers(): Promise { return getBackendSrv().get(`/api/alert-notifiers`); } + +interface IntegrationNameObject { + type: string; + index?: string; +} +export const parseIntegrationName = (integrationName: string): IntegrationNameObject => { + const matches = integrationName.match(/^(\w+)(\[\d+\])?$/); + if (!matches) { + return { type: integrationName, index: undefined }; + } + + return { + type: matches[1], + index: matches[2], + }; +}; + +export const contactPointsStateDtoToModel = (receiversStateDto: ReceiversStateDTO[]): ContactPointsState => { + // init object to return + const contactPointsState: ContactPointsState = { receivers: {}, errorCount: 0 }; + // for each receiver from response + receiversStateDto.forEach((cpState) => { + //init receiver state + contactPointsState.receivers[cpState.name] = { active: cpState.active, notifiers: {}, errorCount: 0 }; + const receiverState = contactPointsState.receivers[cpState.name]; + //update integrations in response + cpState.integrations.forEach((integrationStatusDTO) => { + //update errorcount + const hasError = Boolean(integrationStatusDTO?.lastNotifyAttemptError); + if (hasError) { + receiverState.errorCount += 1; + } + //add integration for this type + const integrationType = getIntegrationType(integrationStatusDTO.name); + if (integrationType) { + //if type still does not exist in IntegrationsTypeState we initialize it with an empty array + if (!receiverState.notifiers[integrationType]) { + receiverState.notifiers[integrationType] = []; + } + // add error status for this type + receiverState.notifiers[integrationType].push(integrationStatusDTO); + } + }); + }); + const errorsCount = Object.values(contactPointsState.receivers).reduce( + (prevCount: number, receiverState: ReceiverState) => prevCount + receiverState.errorCount, + 0 + ); + return { ...contactPointsState, errorCount: errorsCount }; +}; + +export const getIntegrationType = (integrationName: string): string | undefined => + parseIntegrationName(integrationName)?.type; + +export async function fetchContactPointsState(alertManagerSourceName: string): Promise { + try { + const response = await lastValueFrom( + getBackendSrv().fetch({ + url: `/api/alertmanager/${getDatasourceAPIUid(alertManagerSourceName)}/config/api/v1/receivers`, + showErrorAlert: false, + showSuccessAlert: false, + }) + ); + return contactPointsStateDtoToModel(response.data); + } catch (error) { + return contactPointsStateDtoToModel([]); + } +} diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx index 3f485589e34..dc80a0c4b4d 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx @@ -1,8 +1,7 @@ -import { render } from '@testing-library/react'; +import { screen, render, within } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; -import { byRole } from 'testing-library-selector'; import { locationService } from '@grafana/runtime'; import { @@ -53,10 +52,6 @@ const mockNotifier = (type: NotifierType, name: string): NotifierDTO => ({ options: [], }); -const ui = { - table: byRole('table'), -}; - describe('ReceiversTable', () => { it('render receivers with grafana notifiers', async () => { const receivers: Receiver[] = [ @@ -74,14 +69,12 @@ describe('ReceiversTable', () => { await renderReceieversTable(receivers, notifiers); - const table = await ui.table.find(); - - const rows = table.querySelector('tbody')?.querySelectorAll('tr')!; + const rows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(rows).toHaveLength(2); - expect(rows[0].querySelectorAll('td')[0]).toHaveTextContent('with receivers'); - expect(rows[0].querySelectorAll('td')[1]).toHaveTextContent('Google Chat, Sensu Go'); - expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('without receivers'); - expect(rows[1].querySelectorAll('td')[1].textContent).toEqual(''); + expect(rows[0]).toHaveTextContent('with receivers'); + expect(rows[0].querySelector('[data-column="Type"]')).toHaveTextContent('Google Chat, Sensu Go'); + expect(rows[1]).toHaveTextContent('without receivers'); + expect(rows[1].querySelector('[data-column="Type"]')).toHaveTextContent(''); }); it('render receivers with alertmanager notifers', async () => { @@ -117,13 +110,11 @@ describe('ReceiversTable', () => { await renderReceieversTable(receivers, []); - const table = await ui.table.find(); - - const rows = table.querySelector('tbody')?.querySelectorAll('tr')!; + const rows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(rows).toHaveLength(2); - expect(rows[0].querySelectorAll('td')[0]).toHaveTextContent('with receivers'); - expect(rows[0].querySelectorAll('td')[1]).toHaveTextContent('Email, Webhook, OpsGenie, Foo'); - expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('without receivers'); - expect(rows[1].querySelectorAll('td')[1].textContent).toEqual(''); + expect(rows[0]).toHaveTextContent('with receivers'); + expect(rows[0].querySelector('[data-column="Type"]')).toHaveTextContent('Email, Webhook, OpsGenie, Foo'); + expect(rows[1]).toHaveTextContent('without receivers'); + expect(rows[1].querySelector('[data-column="Type"]')).toHaveTextContent(''); }); }); diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx index 86b9c3e07e9..056e3996e50 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -1,11 +1,12 @@ import { css } from '@emotion/css'; +import pluralize from 'pluralize'; import React, { FC, useMemo, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, ConfirmModal, Modal, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, dateTime, dateTimeFormat } from '@grafana/data'; +import { Button, ConfirmModal, Modal, useStyles2, Badge, Icon, Stack } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; -import { useDispatch } from 'app/types'; +import { AlertManagerCortexConfig, Receiver } from 'app/plugins/datasource/alertmanager/types'; +import { useDispatch, AccessControlAction, ContactPointsState, NotifiersState, ReceiversState } from 'app/types'; import { Authorize } from '../../components/Authorize'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; @@ -16,11 +17,192 @@ import { isReceiverUsed } from '../../utils/alertmanager'; import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource'; import { makeAMLink } from '../../utils/misc'; import { extractNotifierTypeCounts } from '../../utils/receivers'; +import { initialAsyncRequestState } from '../../utils/redux'; +import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { ProvisioningBadge } from '../Provisioning'; import { ActionIcon } from '../rules/ActionIcon'; import { ReceiversSection } from './ReceiversSection'; +interface UpdateActionProps extends ActionProps { + onClickDeleteReceiver: (receiverName: string) => void; +} + +function UpdateActions({ permissions, alertManagerName, receiverName, onClickDeleteReceiver }: UpdateActionProps) { + return ( + <> + + + + + onClickDeleteReceiver(receiverName)} + tooltip="Delete contact point" + icon="trash-alt" + /> + + + ); +} +interface ActionProps { + permissions: { + read: AccessControlAction; + create: AccessControlAction; + update: AccessControlAction; + delete: AccessControlAction; + }; + alertManagerName: string; + receiverName: string; +} + +function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps) { + return ( + + + + ); +} +interface ReceiverErrorProps { + errorCount: number; +} + +function ReceiverError({ errorCount }: ReceiverErrorProps) { + return ( + + ); +} +interface ReceiverHealthProps { + errorsByReceiver: number; +} + +function ReceiverHealth({ errorsByReceiver }: ReceiverHealthProps) { + return errorsByReceiver > 0 ? ( + + ) : ( + + ); +} + +const useContactPointsState = (alertManagerName: string) => { + const contactPointsStateRequest = useUnifiedAlertingSelector((state) => state.contactPointsState); + const { result: contactPointsState } = (alertManagerName && contactPointsStateRequest) || initialAsyncRequestState; + const receivers: ReceiversState = contactPointsState?.receivers ?? {}; + const errorStateAvailable = Object.keys(receivers).length > 0; // this logic can change depending on how we implement this in the BE + return { contactPointsState, errorStateAvailable }; +}; + +interface ReceiverItem { + name: string; + types: string[]; + provisioned?: boolean; +} + +interface NotifierStatus { + lastError?: null | string; + lastNotify: string; + lastNotifyDuration: string; + type: string; + sendResolved?: boolean; +} + +type RowTableColumnProps = DynamicTableColumnProps; +type RowItemTableProps = DynamicTableItemProps; + +type NotifierTableColumnProps = DynamicTableColumnProps; +type NotifierItemTableProps = DynamicTableItemProps; + +interface NotifiersTableProps { + notifiersState: NotifiersState; +} + +function LastNotify({ lastNotifyDate }: { lastNotifyDate: string }) { + const isLastNotifyNullDate = lastNotifyDate === '0001-01-01T00:00:00.000Z'; + + if (isLastNotifyNullDate) { + return <>{'-'}; + } else { + return ( + +
{`${dateTime(lastNotifyDate).locale('en').fromNow(true)} ago`}
+ +
{`${dateTimeFormat(lastNotifyDate, { format: 'YYYY-MM-DD HH:mm:ss' })}`}
+
+ ); + } +} + +function NotifiersTable({ notifiersState }: NotifiersTableProps) { + function getNotifierColumns(): NotifierTableColumnProps[] { + return [ + { + id: 'health', + label: 'Health', + renderCell: ({ data: { lastError } }) => { + return ; + }, + size: 0.5, + }, + { + id: 'name', + label: 'Name', + renderCell: ({ data: { type }, id }) => <>{`${type}[${id}]`}, + size: 1, + }, + { + id: 'lastNotify', + label: 'Last delivery attempt', + renderCell: ({ data: { lastNotify } }) => , + size: 3, + }, + { + id: 'lastNotifyDuration', + label: 'Last duration', + renderCell: ({ data: { lastNotifyDuration } }) => <>{lastNotifyDuration}, + size: 1, + }, + { + id: 'sendResolved', + label: 'Send resolved', + renderCell: ({ data: { sendResolved } }) => <>{String(Boolean(sendResolved))}, + size: 1, + }, + ]; + } + const notifierRows: NotifierItemTableProps[] = Object.entries(notifiersState).flatMap((typeState) => + typeState[1].map((notifierStatus, index) => ({ + id: index, + data: { + type: typeState[0], + lastError: notifierStatus.lastNotifyAttemptError, + lastNotify: notifierStatus.lastNotifyAttempt, + lastNotifyDuration: notifierStatus.lastNotifyAttemptDuration, + sendResolved: notifierStatus.sendResolved, + }, + })) + ); + + return ; +} + interface Props { config: AlertManagerCortexConfig; alertManagerName: string; @@ -28,12 +210,13 @@ interface Props { export const ReceiversTable: FC = ({ config, alertManagerName }) => { const dispatch = useDispatch(); - const tableStyles = useStyles2(getAlertTableStyles); const styles = useStyles2(getStyles); const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName); const permissions = getNotificationsPermissions(alertManagerName); const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers); + const { contactPointsState, errorStateAvailable } = useContactPointsState(alertManagerName); + // receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted const [receiverToDelete, setReceiverToDelete] = useState(); const [showCannotDeleteReceiverModal, setShowCannotDeleteReceiverModal] = useState(false); @@ -53,22 +236,33 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => { setReceiverToDelete(undefined); }; - const rows = useMemo( + const rows: RowItemTableProps[] = useMemo( () => - config.alertmanager_config.receivers?.map((receiver) => ({ - name: receiver.name, - types: Object.entries(extractNotifierTypeCounts(receiver, grafanaNotifiers.result ?? [])).map( - ([type, count]) => { - if (count > 1) { - return `${type} (${count})`; + config.alertmanager_config.receivers?.map((receiver: Receiver) => ({ + id: receiver.name, + data: { + name: receiver.name, + types: Object.entries(extractNotifierTypeCounts(receiver, grafanaNotifiers.result ?? [])).map( + ([type, count]) => { + if (count > 1) { + return `${type} (${count})`; + } + return type; } - return type; - } - ), - provisioned: receiver.grafana_managed_receiver_configs?.some((receiver) => receiver.provenance), + ), + provisioned: receiver.grafana_managed_receiver_configs?.some((receiver) => receiver.provenance), + }, })) ?? [], [config, grafanaNotifiers.result] ); + const columns = useGetColumns( + alertManagerName, + errorStateAvailable, + contactPointsState, + onClickDeleteReceiver, + permissions, + isVanillaAM + ); return ( = ({ config, alertManagerName }) => { addButtonLabel="New contact point" addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)} > - - - - - - - - - - - - - - - - - - - {!rows.length && ( - - - - )} - {rows.map((receiver, idx) => ( - - - - - - - - ))} - -
Contact point nameTypeActions
No receivers defined.
- {receiver.name} {receiver.provisioned && } - {receiver.types.join(', ')} - {!isVanillaAM && !receiver.provisioned && ( - <> - - - - - onClickDeleteReceiver(receiver.name)} - tooltip="Delete contact point" - icon="trash-alt" - /> - - - )} - {(isVanillaAM || receiver.provisioned) && ( - - - - )} -
+ ( + + ) + : undefined + } + /> {!!showCannotDeleteReceiverModal && ( = ({ config, alertManagerName }) => { ); }; +function useGetColumns( + alertManagerName: string, + errorStateAvailable: boolean, + contactPointsState: ContactPointsState | undefined, + onClickDeleteReceiver: (receiverName: string) => void, + permissions: { + read: AccessControlAction; + create: AccessControlAction; + update: AccessControlAction; + delete: AccessControlAction; + }, + isVanillaAM: boolean +): RowTableColumnProps[] { + const tableStyles = useStyles2(getAlertTableStyles); + const baseColumns: RowTableColumnProps[] = [ + { + id: 'name', + label: 'Contact point name', + renderCell: ({ data: { name, provisioned } }) => ( + <> + {name} {provisioned && } + + ), + size: 1, + }, + { + id: 'type', + label: 'Type', + renderCell: ({ data: { types } }) => <>{types.join(', ')}, + size: 1, + }, + ]; + const healthColumn: RowTableColumnProps = { + id: 'health', + label: 'Health', + renderCell: ({ data: { name } }) => { + const errorsByReceiver = (contactPointsState: ContactPointsState, receiverName: string) => + contactPointsState?.receivers[receiverName]?.errorCount ?? 0; + return contactPointsState && ; + }, + size: 1, + }; + + return [ + ...baseColumns, + ...(errorStateAvailable ? [healthColumn] : []), + { + id: 'actions', + label: 'Actions', + renderCell: ({ data: { provisioned, name } }) => ( + +
+ {!isVanillaAM && !provisioned && ( + + )} + {(isVanillaAM || provisioned) && ( + + )} +
+
+ ), + size: '100px', + }, + ]; +} + const getStyles = (theme: GrafanaTheme2) => ({ section: css` margin-top: ${theme.spacing(4)}; `, + warning: css` + color: ${theme.colors.warning.text}; + `, + countMessage: css``, }); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index b8a842c78cd..1e2c66e1649 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -13,7 +13,7 @@ import { SilenceCreatePayload, TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; -import { FolderDTO, NotifierDTO, StoreState, ThunkResult } from 'app/types'; +import { ContactPointsState, FolderDTO, NotifierDTO, StoreState, ThunkResult } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace, @@ -50,7 +50,7 @@ import { import { fetchAnnotations } from '../api/annotations'; import { discoverFeatures } from '../api/buildInfo'; import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; -import { fetchNotifiers } from '../api/grafana'; +import { fetchContactPointsState, fetchNotifiers } from '../api/grafana'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { deleteNamespace, @@ -455,6 +455,12 @@ export const fetchGrafanaNotifiersAction = createAsyncThunk( (): Promise => withSerializedError(fetchNotifiers()) ); +export const fetchContactPointsStateAction = createAsyncThunk( + 'unifiedalerting/fetchContactPointsState', + (alertManagerSourceName: string): Promise => + withSerializedError(fetchContactPointsState(alertManagerSourceName)) +); + export const fetchGrafanaAnnotationsAction = createAsyncThunk( 'unifiedalerting/fetchGrafanaAnnotations', (alertId: string): Promise => withSerializedError(fetchAnnotations(alertId)) diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 41d81124d22..2e927acb8c9 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -8,6 +8,7 @@ import { fetchAlertGroupsAction, fetchAlertManagerConfigAction, fetchAmAlertsAction, + fetchContactPointsStateAction, fetchEditableRuleAction, fetchExternalAlertmanagersAction, fetchExternalAlertmanagersConfigAction, @@ -45,6 +46,7 @@ export const reducer = combineReducers({ existingRule: createAsyncSlice('existingRule', fetchEditableRuleAction).reducer, }), grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer, + contactPointsState: createAsyncSlice('contactPointsState', fetchContactPointsStateAction).reducer, saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer, deleteAMConfig: createAsyncSlice('deleteAMConfig', deleteAlertManagerConfigAction).reducer, updateSilence: createAsyncSlice('updateSilence', createOrUpdateSilenceAction).reducer, diff --git a/public/app/features/alerting/unified/utils/constants.ts b/public/app/features/alerting/unified/utils/constants.ts index 6f4332cb281..1ccc1643407 100644 --- a/public/app/features/alerting/unified/utils/constants.ts +++ b/public/app/features/alerting/unified/utils/constants.ts @@ -6,6 +6,7 @@ export const ALERTMANAGER_NAME_QUERY_KEY = 'alertmanager'; export const ALERTMANAGER_NAME_LOCAL_STORAGE_KEY = 'alerting-alertmanager'; export const SILENCES_POLL_INTERVAL_MS = 20000; export const NOTIFICATIONS_POLL_INTERVAL_MS = 20000; +export const CONTACT_POINTS_STATE_INTERVAL_MS = 20000; export const TIMESERIES = 'timeseries'; export const TABLE = 'table'; diff --git a/public/app/types/alerting.ts b/public/app/types/alerting.ts index b01f5894235..67bec642974 100644 --- a/public/app/types/alerting.ts +++ b/public/app/types/alerting.ts @@ -143,6 +143,38 @@ export interface NotificationChannelState { notificationChannel: any; } +export interface NotifierStatus { + lastNotifyAttemptError?: null | string; + lastNotifyAttempt: string; + lastNotifyAttemptDuration: string; + name: string; + sendResolved?: boolean; +} + +export interface NotifiersState { + [key: string]: NotifierStatus[]; // key is the notifier type +} + +export interface ReceiverState { + active: boolean; + notifiers: NotifiersState; + errorCount: number; // errors by receiver +} + +export interface ReceiversState { + [key: string]: ReceiverState; +} + +export interface ContactPointsState { + receivers: ReceiversState; + errorCount: number; +} + +export interface ReceiversStateDTO { + active: boolean; + integrations: NotifierStatus[]; + name: string; +} export interface AlertRulesState { items: AlertRule[]; searchQuery: string;