mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
});
|
||||
|
||||
152
public/app/features/alerting/unified/api/grafana.test.ts
Normal file
152
public/app/features/alerting/unified/api/grafana.test.ts
Normal 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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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``,
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user