Alerting: Add Notification error feedback on contact points view (#56225)

* Alerting: Receivers integrations error feedback: WIP - Add notifications error at the top right on contact points view (#52390)

* Add interfaces for contact point errors

* [WIP] Create fake response for the new service to get contact point errors

* [WIP] Create action an reducer for the new service to get contact point errors

* Fetch fetchContactPointStates in Contact Points tab every 20s and when AM changes

* [WIP] Use store to get error count

* Show number of integrations errors at the contact points main view

* Add warning icon and refactor styles using getStyles

* Change lastNotify type to string instead of DateTime

* Use Stack component from experimental library when it is possible

* Alerting: Add receivers error feedback in contact point list (#52524)

* Refactor types for contact points state

* Add health column in ReceiversTable in case error state is available for this AM

* Create method for converting contact points state DTO to the FE type used in Redux store

* Update types

* Fix indexOf criteria getting integration type

* Change type name to integrationType name

* Change new components to be named functions to follow the FE style-guide

* Fix typos

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>

* Decouple ReceiversTable from Redux state

* Create private useContactPointsState hook to simplify code in ReceiversTable component

* Add tests for getIntegrationType and refactor the method to validate the name

* Add tests for contactPointsStateDtoToModel method

* Remove unnecessary check

* Use Badge compoment for health status in contact point list

* Create new method parseIntegrationName to simplify getting types and index from integration name

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>

* Alerting: Show integrations error feedback when expanding contact point in list (#52920)

* Use DynamicTable for rendering list of contact points and make them expandable if error status is available

* Render expanded content for contact points integrations

* Style and format last notify column

* Add send resolve column to the integration details

* Fix receiver id for DynamicTable row

* Update clock icon in integration state

* Fix tests

* Add PR review sugestions

* Alerting/integrations error feedback handle null dates in response 3 (#55659)

* Update fake response with lastNotify ISO8601 formatted, to be aligned with latest BE changes

* Update LastNotify in ReceiversTable component to handle null date

* Alerting/integrations error feedback handle 404 state not available (#55803)

* Create fetchContactPointsState using the future contact point url and handle 404 error

* Add contact points state tests

* Alerting/update receivers dto naming 2 (#56201)

* Update NotifierStatus naming and fix sendResolved not being updated in UI

* Return always empty ContactPointsState array when catching an error in the request response

* Fix test

* Show notification status only in notifications main view

* Calculate total error count from the final contactPointsState object, to avoid errors when duplicated entries are returned wronly in the response

* Add PR review suggestions

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
This commit is contained in:
Sonia Aguilar
2022-10-06 15:23:38 +02:00
committed by GitHub
parent 9300ae7ce6
commit d8d8ef1aff
10 changed files with 770 additions and 136 deletions

View File

@@ -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<HTMLTableRowElement>('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<HTMLTableRowElement>('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<HTMLTableRowElement>('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);
});
});
});

View File

@@ -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 (
<div className={styles.warning} data-testid="receivers-notification-error">
<Stack alignItems="flex-end" direction="column">
<Stack alignItems="center">
<Icon name="exclamation-triangle" />
<div className={styles.countMessage}>
{`${errorCount} ${pluralize('error', errorCount)} with contact points`}
</div>
</Stack>
<div>{'Some alert notifications might not be delivered'}</div>
</Stack>
</div>
);
}
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 (
<AlertingPageWrapper pageId="receivers" pageNav={pageNav}>
<AlertManagerPicker
current={alertManagerSourceName}
disabled={disableAmSelect}
onChange={setAlertManagerSourceName}
dataSources={alertManagers}
/>
<div className={styles.headingContainer}>
<AlertManagerPicker
current={alertManagerSourceName}
disabled={disableAmSelect}
onChange={setAlertManagerSourceName}
dataSources={alertManagers}
/>
{shouldRenderNotificationStatus && integrationsErrorCount > 0 && (
<NotificationError errorCount={integrationsErrorCount} />
)}
</div>
{error && !loading && (
<Alert severity="error" title="Error loading Alertmanager config">
{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;
`,
});

View File

@@ -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: {},
});
});
});

View File

@@ -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<NotifierDTO[]> {
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<ContactPointsState> {
try {
const response = await lastValueFrom(
getBackendSrv().fetch<ReceiversStateDTO[]>({
url: `/api/alertmanager/${getDatasourceAPIUid(alertManagerSourceName)}/config/api/v1/receivers`,
showErrorAlert: false,
showSuccessAlert: false,
})
);
return contactPointsStateDtoToModel(response.data);
} catch (error) {
return contactPointsStateDtoToModel([]);
}
}

View File

@@ -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<HTMLTableElement>('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('');
});
});

View File

@@ -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 (
<>
<Authorize actions={[permissions.update]}>
<ActionIcon
aria-label="Edit"
data-testid="edit"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiverName)}/edit`,
alertManagerName
)}
tooltip="Edit contact point"
icon="pen"
/>
</Authorize>
<Authorize actions={[permissions.delete]}>
<ActionIcon
onClick={() => onClickDeleteReceiver(receiverName)}
tooltip="Delete contact point"
icon="trash-alt"
/>
</Authorize>
</>
);
}
interface ActionProps {
permissions: {
read: AccessControlAction;
create: AccessControlAction;
update: AccessControlAction;
delete: AccessControlAction;
};
alertManagerName: string;
receiverName: string;
}
function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps) {
return (
<Authorize actions={[permissions.update]}>
<ActionIcon
data-testid="view"
to={makeAMLink(`/alerting/notifications/receivers/${encodeURIComponent(receiverName)}/edit`, alertManagerName)}
tooltip="View contact point"
icon="file-alt"
/>
</Authorize>
);
}
interface ReceiverErrorProps {
errorCount: number;
}
function ReceiverError({ errorCount }: ReceiverErrorProps) {
return (
<Badge
color="orange"
icon="exclamation-triangle"
text={`${errorCount} ${pluralize('error', errorCount)}`}
tooltip={`${errorCount} ${pluralize('error', errorCount)} detected in this contact point`}
/>
);
}
interface ReceiverHealthProps {
errorsByReceiver: number;
}
function ReceiverHealth({ errorsByReceiver }: ReceiverHealthProps) {
return errorsByReceiver > 0 ? (
<ReceiverError errorCount={errorsByReceiver} />
) : (
<Badge color="green" text="OK" tooltip="No errors detected" />
);
}
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<ReceiverItem>;
type RowItemTableProps = DynamicTableItemProps<ReceiverItem>;
type NotifierTableColumnProps = DynamicTableColumnProps<NotifierStatus>;
type NotifierItemTableProps = DynamicTableItemProps<NotifierStatus>;
interface NotifiersTableProps {
notifiersState: NotifiersState;
}
function LastNotify({ lastNotifyDate }: { lastNotifyDate: string }) {
const isLastNotifyNullDate = lastNotifyDate === '0001-01-01T00:00:00.000Z';
if (isLastNotifyNullDate) {
return <>{'-'}</>;
} else {
return (
<Stack alignItems="center">
<div>{`${dateTime(lastNotifyDate).locale('en').fromNow(true)} ago`}</div>
<Icon name="clock-nine" />
<div>{`${dateTimeFormat(lastNotifyDate, { format: 'YYYY-MM-DD HH:mm:ss' })}`}</div>
</Stack>
);
}
}
function NotifiersTable({ notifiersState }: NotifiersTableProps) {
function getNotifierColumns(): NotifierTableColumnProps[] {
return [
{
id: 'health',
label: 'Health',
renderCell: ({ data: { lastError } }) => {
return <ReceiverHealth errorsByReceiver={lastError ? 1 : 0} />;
},
size: 0.5,
},
{
id: 'name',
label: 'Name',
renderCell: ({ data: { type }, id }) => <>{`${type}[${id}]`}</>,
size: 1,
},
{
id: 'lastNotify',
label: 'Last delivery attempt',
renderCell: ({ data: { lastNotify } }) => <LastNotify lastNotifyDate={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 <DynamicTable items={notifierRows} cols={getNotifierColumns()} />;
}
interface Props {
config: AlertManagerCortexConfig;
alertManagerName: string;
@@ -28,12 +210,13 @@ interface Props {
export const ReceiversTable: FC<Props> = ({ 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<string>();
const [showCannotDeleteReceiverModal, setShowCannotDeleteReceiverModal] = useState(false);
@@ -53,22 +236,33 @@ export const ReceiversTable: FC<Props> = ({ 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 (
<ReceiversSection
@@ -79,79 +273,18 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
addButtonLabel="New contact point"
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
>
<table className={tableStyles.table} data-testid="receivers-table">
<colgroup>
<col />
<col />
<Authorize actions={[permissions.update, permissions.delete]}>
<col />
</Authorize>
</colgroup>
<thead>
<tr>
<th>Contact point name</th>
<th>Type</th>
<Authorize actions={[permissions.update, permissions.delete]}>
<th>Actions</th>
</Authorize>
</tr>
</thead>
<tbody>
{!rows.length && (
<tr className={tableStyles.evenRow}>
<td colSpan={3}>No receivers defined.</td>
</tr>
)}
{rows.map((receiver, idx) => (
<tr key={receiver.name} className={idx % 2 === 0 ? tableStyles.evenRow : undefined}>
<td>
{receiver.name} {receiver.provisioned && <ProvisioningBadge />}
</td>
<td>{receiver.types.join(', ')}</td>
<Authorize actions={[permissions.update, permissions.delete]}>
<td className={tableStyles.actionsCell}>
{!isVanillaAM && !receiver.provisioned && (
<>
<Authorize actions={[permissions.update]}>
<ActionIcon
aria-label="Edit"
data-testid="edit"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerName
)}
tooltip="Edit contact point"
icon="pen"
/>
</Authorize>
<Authorize actions={[permissions.delete]}>
<ActionIcon
onClick={() => onClickDeleteReceiver(receiver.name)}
tooltip="Delete contact point"
icon="trash-alt"
/>
</Authorize>
</>
)}
{(isVanillaAM || receiver.provisioned) && (
<Authorize actions={[permissions.update]}>
<ActionIcon
data-testid="view"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerName
)}
tooltip="View contact point"
icon="file-alt"
/>
</Authorize>
)}
</td>
</Authorize>
</tr>
))}
</tbody>
</table>
<DynamicTable
items={rows}
cols={columns}
isExpandable={errorStateAvailable}
renderExpandedContent={
errorStateAvailable
? ({ data: { name } }) => (
<NotifiersTable notifiersState={contactPointsState?.receivers[name]?.notifiers ?? {}} />
)
: undefined
}
/>
{!!showCannotDeleteReceiverModal && (
<Modal
isOpen={true}
@@ -183,8 +316,83 @@ export const ReceiversTable: FC<Props> = ({ 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 && <ProvisioningBadge />}
</>
),
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 && <ReceiverHealth errorsByReceiver={errorsByReceiver(contactPointsState, name)} />;
},
size: 1,
};
return [
...baseColumns,
...(errorStateAvailable ? [healthColumn] : []),
{
id: 'actions',
label: 'Actions',
renderCell: ({ data: { provisioned, name } }) => (
<Authorize actions={[permissions.update, permissions.delete]}>
<div className={tableStyles.actionsCell}>
{!isVanillaAM && !provisioned && (
<UpdateActions
permissions={permissions}
alertManagerName={alertManagerName}
receiverName={name}
onClickDeleteReceiver={onClickDeleteReceiver}
/>
)}
{(isVanillaAM || provisioned) && (
<ViewAction permissions={permissions} alertManagerName={alertManagerName} receiverName={name} />
)}
</div>
</Authorize>
),
size: '100px',
},
];
}
const getStyles = (theme: GrafanaTheme2) => ({
section: css`
margin-top: ${theme.spacing(4)};
`,
warning: css`
color: ${theme.colors.warning.text};
`,
countMessage: css``,
});

View File

@@ -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,
@@ -51,7 +51,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,
@@ -458,6 +458,12 @@ export const fetchGrafanaNotifiersAction = createAsyncThunk(
(): Promise<NotifierDTO[]> => withSerializedError(fetchNotifiers())
);
export const fetchContactPointsStateAction = createAsyncThunk(
'unifiedalerting/fetchContactPointsState',
(alertManagerSourceName: string): Promise<ContactPointsState> =>
withSerializedError(fetchContactPointsState(alertManagerSourceName))
);
export const fetchGrafanaAnnotationsAction = createAsyncThunk(
'unifiedalerting/fetchGrafanaAnnotations',
(alertId: string): Promise<StateHistoryItem[]> => withSerializedError(fetchAnnotations(alertId))

View File

@@ -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,

View File

@@ -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';

View File

@@ -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;