Alerting: Remove url based external alertmanagers config (#57918)

* Remove URL-based alertmanagers from endpoint config

* WIP

* Add migration and alertmanagers from admin_configuration

* Empty comment removed

* set BasicAuth true when user is present in url

* Remove Alertmanagers from GET /admin_config payload

* Remove URL-based alertmanager configuration from UI

* Fix new uid generation in external alertmanagers migration

* Fix tests for URL-based external alertmanagers

* Fix API tests

* Add more tests, move migration code to separate file, and remove possible am duplicate urls

* Fix edge cases in migration

* Fix imports

* Remove useless fields and fix created_at/updated_at retrieval

Co-authored-by: George Robinson <george.robinson@grafana.com>
Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
This commit is contained in:
Alex Moreno
2022-11-10 16:34:13 +01:00
committed by GitHub
parent 738e023d13
commit 45facbba11
21 changed files with 411 additions and 796 deletions

View File

@@ -1,139 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { FC, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Field, FieldArray, Form, Icon, Input, Modal, useStyles2 } from '@grafana/ui';
import { AlertmanagerUrl } from 'app/plugins/datasource/alertmanager/types';
interface Props {
onClose: () => void;
alertmanagers: AlertmanagerUrl[];
onChangeAlertmanagerConfig: (alertmanagers: string[]) => void;
}
export const AddAlertManagerModal: FC<Props> = ({ alertmanagers, onChangeAlertmanagerConfig, onClose }) => {
const styles = useStyles2(getStyles);
const defaultValues: Record<string, AlertmanagerUrl[]> = useMemo(
() => ({
alertmanagers: alertmanagers,
}),
[alertmanagers]
);
const modalTitle = (
<div className={styles.modalTitle}>
<Icon name="bell" className={styles.modalIcon} />
<h3>Add Alertmanager</h3>
</div>
);
const onSubmit = (values: Record<string, AlertmanagerUrl[]>) => {
onChangeAlertmanagerConfig(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url)));
onClose();
};
return (
<Modal title={modalTitle} isOpen={true} onDismiss={onClose} className={styles.modal}>
<div className={styles.description}>
We use a service discovery method to find existing Alertmanagers for a given URL.
</div>
<Form onSubmit={onSubmit} defaultValues={defaultValues}>
{({ register, control, errors }) => (
<div>
<FieldArray control={control} name="alertmanagers">
{({ fields, append, remove }) => (
<div className={styles.fieldArray}>
<div className={styles.bold}>Source url</div>
<div className={styles.muted}>
Authentication can be done via URL (e.g. user:password@myalertmanager.com) and only the Alertmanager
v2 API is supported. The suffix is added internally, there is no need to specify it.
</div>
{fields.map((field, index) => {
return (
<Field
invalid={!!errors?.alertmanagers?.[index]}
error="Field is required"
key={`${field.id}-${index}`}
>
<Input
className={styles.input}
defaultValue={field.url}
{...register(`alertmanagers.${index}.url`, { required: true })}
placeholder="http://localhost:9093"
addonAfter={
<Button
aria-label="Remove alertmanager"
type="button"
onClick={() => remove(index)}
variant="destructive"
className={styles.destroyInputRow}
>
<Icon name="trash-alt" />
</Button>
}
/>
</Field>
);
})}
<Button type="button" variant="secondary" onClick={() => append({ url: '' })}>
Add URL
</Button>
</div>
)}
</FieldArray>
<div>
<Button type="submit" onSubmit={() => onSubmit}>
Add Alertmanagers
</Button>
</div>
</div>
)}
</Form>
</Modal>
);
};
function cleanAlertmanagerUrl(url: string): string {
return url.replace(/\/$/, '').replace(/\/api\/v[1|2]\/alerts/i, '');
}
const getStyles = (theme: GrafanaTheme2) => {
const muted = css`
color: ${theme.colors.text.secondary};
`;
return {
description: cx(
css`
margin-bottom: ${theme.spacing(2)};
`,
muted
),
muted: muted,
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
modal: css``,
modalIcon: cx(
muted,
css`
margin-right: ${theme.spacing(1)};
`
),
modalTitle: css`
display: flex;
`,
input: css`
margin-bottom: ${theme.spacing(1)};
margin-right: ${theme.spacing(1)};
`,
inputRow: css`
display: flex;
`,
destroyInputRow: css`
padding: ${theme.spacing(1)};
`,
fieldArray: css`
margin-bottom: ${theme.spacing(4)};
`,
};
};

View File

@@ -18,7 +18,7 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext
return (
<>
<h5>Alertmanagers data sources</h5>
<h5>Alertmanagers Receiving Grafana-managed alerts</h5>
<div className={styles.muted}>
Alertmanager data sources support a configuration setting that allows you to choose to send Grafana-managed
alerts to that Alertmanager. <br />
@@ -102,6 +102,8 @@ export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMd
export const getStyles = (theme: GrafanaTheme2) => ({
muted: css`
font-size: ${theme.typography.bodySmall.fontSize};
line-height: ${theme.typography.bodySmall.lineHeight};
color: ${theme.colors.text.secondary};
`,
externalHeading: css`

View File

@@ -1,28 +1,15 @@
import { css, cx } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
Alert,
Button,
ConfirmModal,
Field,
HorizontalGroup,
Icon,
RadioButtonGroup,
Tooltip,
useStyles2,
useTheme2,
} from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { Alert, Field, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { loadDataSources } from 'app/features/datasources/state/actions';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { useExternalAmSelector, useExternalDataSourceAlertmanagers } from '../../hooks/useExternalAmSelector';
import { useExternalDataSourceAlertmanagers } from '../../hooks/useExternalAmSelector';
import { AddAlertManagerModal } from './AddAlertManagerModal';
import { ExternalAlertmanagerDataSources } from './ExternalAlertmanagerDataSources';
const alertmanagerChoices: Array<SelectableValue<AlertmanagerChoice>> = [
@@ -34,10 +21,7 @@ const alertmanagerChoices: Array<SelectableValue<AlertmanagerChoice>> = [
export const ExternalAlertmanagers = () => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [modalState, setModalState] = useState({ open: false, payload: [{ url: '' }] });
const [deleteModalState, setDeleteModalState] = useState({ open: false, index: 0 });
const externalAlertManagers = useExternalAmSelector();
const externalDsAlertManagers = useExternalDataSourceAlertmanagers();
const {
@@ -53,84 +37,15 @@ export const ExternalAlertmanagers = () => {
useGetExternalAlertmanagersQuery(undefined, { pollingInterval: 5000 });
const alertmanagersChoice = externalAlertmanagerConfig?.alertmanagersChoice;
const theme = useTheme2();
useEffect(() => {
dispatch(loadDataSources());
}, [dispatch]);
const onDelete = useCallback(
(index: number) => {
// to delete we need to filter the alertmanager from the list and repost
const newList = (externalAlertManagers ?? [])
.filter((am, i) => i !== index)
.map((am) => {
return am.url;
});
saveExternalAlertManagers({
alertmanagers: newList,
alertmanagersChoice: alertmanagersChoice ?? AlertmanagerChoice.All,
});
setDeleteModalState({ open: false, index: 0 });
},
[externalAlertManagers, saveExternalAlertManagers, alertmanagersChoice]
);
const onEdit = useCallback(() => {
const ams = externalAlertManagers ? [...externalAlertManagers] : [{ url: '' }];
setModalState((state) => ({
...state,
open: true,
payload: ams,
}));
}, [setModalState, externalAlertManagers]);
const onOpenModal = useCallback(() => {
setModalState((state) => {
const ams = externalAlertManagers ? [...externalAlertManagers, { url: '' }] : [{ url: '' }];
return {
...state,
open: true,
payload: ams,
};
});
}, [externalAlertManagers]);
const onCloseModal = useCallback(() => {
setModalState((state) => ({
...state,
open: false,
}));
}, [setModalState]);
const onChangeAlertmanagerChoice = (alertmanagersChoice: AlertmanagerChoice) => {
saveExternalAlertManagers({ alertmanagers: externalAlertManagers.map((am) => am.url), alertmanagersChoice });
saveExternalAlertManagers({ alertmanagersChoice });
};
const onChangeAlertmanagers = (alertmanagers: string[]) => {
saveExternalAlertManagers({
alertmanagers,
alertmanagersChoice: alertmanagersChoice ?? AlertmanagerChoice.All,
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return theme.colors.success.main;
case 'pending':
return theme.colors.warning.main;
default:
return theme.colors.error.main;
}
};
const noAlertmanagers = externalAlertManagers?.length === 0;
return (
<div>
<h4>External Alertmanagers</h4>
@@ -142,15 +57,10 @@ export const ExternalAlertmanagers = () => {
For more information, refer to our documentation.
</Alert>
<ExternalAlertmanagerDataSources
alertmanagers={externalDsAlertManagers}
inactive={alertmanagersChoice === AlertmanagerChoice.Internal}
/>
<div className={styles.amChoice}>
<Field
label="Send alerts to"
description="Configures how the Grafana alert rule evaluation engine Alertmanager handles your alerts. Internal (Grafana built-in Alertmanager), External (All Alertmanagers configured above), or both."
description="Configures how the Grafana alert rule evaluation engine Alertmanager handles your alerts. Internal (Grafana built-in Alertmanager), External (All Alertmanagers configured below), or both."
>
<RadioButtonGroup
options={alertmanagerChoices}
@@ -160,95 +70,10 @@ export const ExternalAlertmanagers = () => {
</Field>
</div>
<h5>Alertmanagers by URL</h5>
<Alert severity="warning" title="Deprecation Notice">
The URL-based configuration of Alertmanagers is deprecated and will be removed in Grafana 9.2.0.
<br />
Use Alertmanager data sources to configure your external Alertmanagers.
</Alert>
<div className={styles.muted}>
You can have your Grafana managed alerts be delivered to one or many external Alertmanager(s) in addition to the
internal Alertmanager by specifying their URLs below.
</div>
<div className={styles.actions}>
{!noAlertmanagers && (
<Button type="button" onClick={onOpenModal}>
Add Alertmanager
</Button>
)}
</div>
{noAlertmanagers ? (
<EmptyListCTA
title="You have not added any external alertmanagers"
onClick={onOpenModal}
buttonTitle="Add Alertmanager"
buttonIcon="bell-slash"
/>
) : (
<>
<table className={cx('filter-table form-inline filter-table--hover', styles.table)}>
<thead>
<tr>
<th>Url</th>
<th>Status</th>
<th style={{ width: '2%' }}>Action</th>
</tr>
</thead>
<tbody>
{externalAlertManagers?.map((am, index) => {
return (
<tr key={index}>
<td>
<span className={styles.url}>{am.url}</span>
{am.actualUrl ? (
<Tooltip content={`Discovered ${am.actualUrl} from ${am.url}`} theme="info">
<Icon name="info-circle" />
</Tooltip>
) : null}
</td>
<td>
<Icon name="heart" style={{ color: getStatusColor(am.status) }} title={am.status} />
</td>
<td>
<HorizontalGroup>
<Button variant="secondary" type="button" onClick={onEdit} aria-label="Edit alertmanager">
<Icon name="pen" />
</Button>
<Button
variant="destructive"
aria-label="Remove alertmanager"
type="button"
onClick={() => setDeleteModalState({ open: true, index })}
>
<Icon name="trash-alt" />
</Button>
</HorizontalGroup>
</td>
</tr>
);
})}
</tbody>
</table>
</>
)}
<ConfirmModal
isOpen={deleteModalState.open}
title="Remove Alertmanager"
body="Are you sure you want to remove this Alertmanager"
confirmText="Remove"
onConfirm={() => onDelete(deleteModalState.index)}
onDismiss={() => setDeleteModalState({ open: false, index: 0 })}
<ExternalAlertmanagerDataSources
alertmanagers={externalDsAlertManagers}
inactive={alertmanagersChoice === AlertmanagerChoice.Internal}
/>
{modalState.open && (
<AddAlertManagerModal
onClose={onCloseModal}
alertmanagers={modalState.payload}
onChangeAlertmanagerConfig={onChangeAlertmanagers}
/>
)}
</div>
);
};
@@ -257,9 +82,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({
url: css`
margin-right: ${theme.spacing(1)};
`,
muted: css`
color: ${theme.colors.text.secondary};
`,
actions: css`
margin-top: ${theme.spacing(2)};
display: flex;

View File

@@ -8,12 +8,12 @@ import 'whatwg-fetch';
import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
import { config } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { AlertmanagerChoice, AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { mockDataSource, mockDataSourcesStore, mockStore } from '../mocks';
import { mockAlertmanagerConfigResponse, mockAlertmanagersResponse } from '../mocks/alertmanagerApi';
import { mockAlertmanagersResponse } from '../mocks/alertmanagerApi';
import { useExternalAmSelector, useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
const server = setupServer();
@@ -34,184 +34,6 @@ afterAll(() => {
server.close();
});
describe('useExternalAmSelector', () => {
it('should have one in pending', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [],
droppedAlertManagers: [],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['some/url/to/am'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
status: 'pending',
actualUrl: '',
},
]);
});
it('should have one active, one pending', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }],
droppedAlertManagers: [],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['some/url/to/am', 'some/url/to/am1'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: '',
status: 'pending',
},
]);
});
it('should have two active', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }, { url: 'some/url/to/am1/api/v2/alerts' }],
droppedAlertManagers: [],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['some/url/to/am', 'some/url/to/am1'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: 'some/url/to/am1/api/v2/alerts',
status: 'active',
},
]);
});
it('should have one active, one dropped, one pending', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }],
droppedAlertManagers: [{ url: 'some/dropped/url/api/v2/alerts' }],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['some/url/to/am', 'some/url/to/am1'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: '',
status: 'pending',
},
{
url: 'some/dropped/url',
actualUrl: 'some/dropped/url/api/v2/alerts',
status: 'dropped',
},
]);
});
it('The number of alert managers should match config entries when there are multiple entries of the same url', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [
{ url: 'same/url/to/am/api/v2/alerts' },
{ url: 'same/url/to/am/api/v2/alerts' },
{ url: 'same/url/to/am/api/v2/alerts' },
],
droppedAlertManagers: [],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['same/url/to/am', 'same/url/to/am', 'same/url/to/am'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers.length).toBe(3);
expect(alertmanagers).toEqual([
{
url: 'same/url/to/am',
actualUrl: 'same/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'same/url/to/am',
actualUrl: 'same/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'same/url/to/am',
actualUrl: 'same/url/to/am/api/v2/alerts',
status: 'active',
},
]);
});
});
describe('useExternalDataSourceAlertmanagers', () => {
it('Should merge data sources information from config and api responses', async () => {
// Arrange

View File

@@ -7,54 +7,6 @@ import { useSelector } from 'app/types';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { getAlertManagerDataSources } from '../utils/datasource';
const SUFFIX_REGEX = /\/api\/v[1|2]\/alerts/i;
type AlertmanagerConfig = { url: string; status: string; actualUrl: string };
export function useExternalAmSelector(): AlertmanagerConfig[] | [] {
const { useGetExternalAlertmanagersQuery, useGetExternalAlertmanagerConfigQuery } = alertmanagerApi;
const { currentData: discoveredAlertmanagers } = useGetExternalAlertmanagersQuery();
const { currentData: alertmanagerConfig } = useGetExternalAlertmanagerConfigQuery();
if (!discoveredAlertmanagers || !alertmanagerConfig) {
return [];
}
const enabledAlertmanagers: AlertmanagerConfig[] = [];
const droppedAlertmanagers: AlertmanagerConfig[] = discoveredAlertmanagers.droppedAlertManagers.map((am) => ({
url: am.url.replace(SUFFIX_REGEX, ''),
status: 'dropped',
actualUrl: am.url,
}));
for (const url of alertmanagerConfig.alertmanagers) {
if (discoveredAlertmanagers.activeAlertManagers.length === 0) {
enabledAlertmanagers.push({
url: url,
status: 'pending',
actualUrl: '',
});
} else {
const matchingActiveAM = discoveredAlertmanagers.activeAlertManagers.find(
(am) => am.url === `${url}/api/v2/alerts`
);
matchingActiveAM
? enabledAlertmanagers.push({
url: matchingActiveAM.url.replace(SUFFIX_REGEX, ''),
status: 'active',
actualUrl: matchingActiveAM.url,
})
: enabledAlertmanagers.push({
url: url,
status: 'pending',
actualUrl: '',
});
}
}
return [...enabledAlertmanagers, ...droppedAlertmanagers];
}
export interface ExternalDataSourceAM {
dataSource: DataSourceInstanceSettings<AlertManagerDataSourceJsonData>;
url?: string;

View File

@@ -1,10 +1,7 @@
import { rest } from 'msw';
import { SetupServerApi } from 'msw/node';
import {
ExternalAlertmanagerConfig,
ExternalAlertmanagersResponse,
} from '../../../../plugins/datasource/alertmanager/types';
import { ExternalAlertmanagersResponse } from '../../../../plugins/datasource/alertmanager/types';
import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi';
export function mockAlertmanagerChoiceResponse(server: SetupServerApi, respose: AlertmanagersChoiceResponse) {
@@ -14,7 +11,3 @@ export function mockAlertmanagerChoiceResponse(server: SetupServerApi, respose:
export function mockAlertmanagersResponse(server: SetupServerApi, response: ExternalAlertmanagersResponse) {
server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}
export function mockAlertmanagerConfigResponse(server: SetupServerApi, response: ExternalAlertmanagerConfig) {
server.use(rest.get('/api/v1/ngalert/admin_config', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}

View File

@@ -286,7 +286,6 @@ export enum AlertmanagerChoice {
}
export interface ExternalAlertmanagerConfig {
alertmanagers: string[];
alertmanagersChoice: AlertmanagerChoice;
}