Alerting: Configure alert manager data source as an external AM (#52081)

Co-authored-by: Jean-Philippe Quéméner <JohnnyQQQQ@users.noreply.github.com>
Co-authored-by: gotjosh <josue.abreu@gmail.com>
Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com>
pull/53024/head
Konrad Lalik 3 years ago committed by GitHub
parent 1fc9f6f1c6
commit 54f2c056f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .betterer.results
  2. 16
      pkg/services/ngalert/sender/router.go
  3. 19
      pkg/services/ngalert/sender/router_test.go
  4. 10
      public/app/features/alerting/unified/Admin.tsx
  5. 122
      public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx
  6. 89
      public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
  7. 7
      public/app/features/alerting/unified/components/rules/RuleListErrors.tsx
  8. 167
      public/app/features/alerting/unified/hooks/useExternalAMSelector.test.ts
  9. 416
      public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx
  10. 75
      public/app/features/alerting/unified/hooks/useExternalAmSelector.ts
  11. 24
      public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
  12. 36
      public/app/features/alerting/unified/mocks.ts
  13. 4
      public/app/features/alerting/unified/utils/datasource.ts
  14. 6
      public/app/features/alerting/unified/utils/misc.ts
  15. 25
      public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
  16. 14
      public/app/plugins/datasource/alertmanager/types.ts

@ -6302,9 +6302,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Do not use any type assertions.", "10"]
],
"public/app/plugins/datasource/alertmanager/ConfigEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/alertmanager/DataSource.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

@ -10,6 +10,7 @@ import (
"github.com/benbjohnson/clock"
"github.com/grafana/grafana/pkg/api/datasource"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@ -126,6 +127,8 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase() error {
continue
}
d.logger.Debug("alertmanagers found in the configuration", "alertmanagers", cfg.Alertmanagers)
// We have a running sender, check if we need to apply a new config.
if ok {
if d.externalAlertmanagersCfgHash[cfg.OrgID] == cfg.AsSHA256() {
@ -218,16 +221,17 @@ func (d *AlertsRouter) alertmanagersFromDatasources(orgID int64) ([]string, erro
}
func (d *AlertsRouter) buildExternalURL(ds *datasources.DataSource) (string, error) {
amURL := ds.Url
// We re-use the same parsing logic as the datasource to make sure it matches whatever output the user received
// when doing the healthcheck.
parsed, err := datasource.ValidateURL(datasources.DS_ALERTMANAGER, ds.Url)
if err != nil {
return "", fmt.Errorf("failed to parse alertmanager datasource url: %w", err)
}
// if basic auth is enabled we need to build the url with basic auth baked in
if !ds.BasicAuth {
return amURL, nil
return parsed.String(), nil
}
parsed, err := url.Parse(ds.Url)
if err != nil {
return "", fmt.Errorf("failed to parse alertmanager datasource url: %w", err)
}
password := d.secretService.GetDecryptedValue(context.Background(), ds.SecureJsonData, "basicAuthPassword", "")
if password == "" {
return "", fmt.Errorf("basic auth enabled but no password set")

@ -413,6 +413,25 @@ func TestBuildExternalURL(t *testing.T) {
},
expectedURL: "https://johndoe:123@localhost:9000/path/to/am",
},
{
name: "with no scheme specified in the datasource",
ds: &datasources.DataSource{
Url: "localhost:9000/path/to/am",
BasicAuth: true,
BasicAuthUser: "johndoe",
SecureJsonData: map[string][]byte{
"basicAuthPassword": []byte("123"),
},
},
expectedURL: "http://johndoe:123@localhost:9000/path/to/am",
},
{
name: "with no scheme specified not auth in the datasource",
ds: &datasources.DataSource{
Url: "localhost:9000/path/to/am",
},
expectedURL: "http://localhost:9000/path/to/am",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {

@ -3,12 +3,20 @@ import React from 'react';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import AlertmanagerConfig from './components/admin/AlertmanagerConfig';
import { ExternalAlertmanagers } from './components/admin/ExternalAlertmanagers';
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
import { useAlertManagersByPermission } from './hooks/useAlertManagerSources';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
export default function Admin(): JSX.Element {
const alertManagers = useAlertManagersByPermission('notification');
const [alertManagerSourceName] = useAlertManagerSourceName(alertManagers);
const isGrafanaAmSelected = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
return (
<AlertingPageWrapper pageId="alerting-admin">
<AlertmanagerConfig test-id="admin-alertmanagerconfig" />
<ExternalAlertmanagers test-id="admin-externalalertmanagers" />
{isGrafanaAmSelected && <ExternalAlertmanagers test-id="admin-externalalertmanagers" />}
</AlertingPageWrapper>
);
}

@ -0,0 +1,122 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, CallToActionCard, Card, Icon, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
import { ExternalDataSourceAM } from '../../hooks/useExternalAmSelector';
import { makeDataSourceLink } from '../../utils/misc';
export interface ExternalAlertManagerDataSourcesProps {
alertmanagers: ExternalDataSourceAM[];
inactive: boolean;
}
export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: ExternalAlertManagerDataSourcesProps) {
const styles = useStyles2(getStyles);
return (
<>
<h5>Alertmanagers data sources</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 />
Below, you can see the list of all Alertmanager data sources that have this setting enabled.
</div>
{alertmanagers.length === 0 && (
<CallToActionCard
message={
<div>
There are no Alertmanager data sources configured to receive Grafana-managed alerts. <br />
You can change this by selecting Receive Grafana Alerts in a data source configuration.
</div>
}
callToActionElement={<LinkButton href="/datasources">Go to data sources</LinkButton>}
className={styles.externalDsCTA}
/>
)}
{alertmanagers.length > 0 && (
<div className={styles.externalDs}>
{alertmanagers.map((am) => (
<ExternalAMdataSourceCard key={am.dataSource.uid} alertmanager={am} inactive={inactive} />
))}
</div>
)}
</>
);
}
interface ExternalAMdataSourceCardProps {
alertmanager: ExternalDataSourceAM;
inactive: boolean;
}
export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMdataSourceCardProps) {
const styles = useStyles2(getStyles);
const { dataSource, status, statusInconclusive, url } = alertmanager;
return (
<Card>
<Card.Heading className={styles.externalHeading}>
{dataSource.name}{' '}
{statusInconclusive && (
<Tooltip content="Multiple Alertmangers have the same URL configured. The state might be inconclusive.">
<Icon name="exclamation-triangle" size="md" className={styles.externalWarningIcon} />
</Tooltip>
)}
</Card.Heading>
<Card.Figure>
<img
src="public/app/plugins/datasource/alertmanager/img/logo.svg"
alt=""
height="40px"
width="40px"
style={{ objectFit: 'contain' }}
/>
</Card.Figure>
<Card.Tags>
{inactive ? (
<Badge
text="Inactive"
color="red"
tooltip="Grafana is configured to send alerts to the built-in internal Alertmanager only. External Alertmanagers do not receive any alerts."
/>
) : (
<Badge
text={capitalize(status)}
color={status === 'dropped' ? 'red' : status === 'active' ? 'green' : 'orange'}
/>
)}
</Card.Tags>
<Card.Meta>{url}</Card.Meta>
<Card.Actions>
<LinkButton href={makeDataSourceLink(dataSource)} size="sm" variant="secondary">
Go to datasouce
</LinkButton>
</Card.Actions>
</Card>
);
}
export const getStyles = (theme: GrafanaTheme2) => ({
muted: css`
color: ${theme.colors.text.secondary};
`,
externalHeading: css`
justify-content: flex-start;
`,
externalWarningIcon: css`
margin: ${theme.spacing(0, 1)};
fill: ${theme.colors.warning.main};
`,
externalDs: css`
display: grid;
gap: ${theme.spacing(1)};
padding: ${theme.spacing(2, 0)};
`,
externalDsCTA: css`
margin: ${theme.spacing(2, 0)};
`,
});

@ -2,8 +2,9 @@ import { css, cx } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
Alert,
Button,
ConfirmModal,
Field,
@ -15,9 +16,11 @@ import {
useTheme2,
} from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { loadDataSources } from 'app/features/datasources/state/actions';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { StoreState } from 'app/types/store';
import { useExternalAmSelector } from '../../hooks/useExternalAmSelector';
import { useExternalAmSelector, useExternalDataSourceAlertmanagers } from '../../hooks/useExternalAmSelector';
import {
addExternalAlertmanagersAction,
fetchExternalAlertmanagersAction,
@ -25,11 +28,12 @@ import {
} from '../../state/actions';
import { AddAlertManagerModal } from './AddAlertManagerModal';
import { ExternalAlertmanagerDataSources } from './ExternalAlertmanagerDataSources';
const alertmanagerChoices = [
{ value: 'internal', label: 'Only Internal' },
{ value: 'external', label: 'Only External' },
{ value: 'all', label: 'Both internal and external' },
const alertmanagerChoices: Array<SelectableValue<AlertmanagerChoice>> = [
{ value: AlertmanagerChoice.Internal, label: 'Only Internal' },
{ value: AlertmanagerChoice.External, label: 'Only External' },
{ value: AlertmanagerChoice.All, label: 'Both internal and external' },
];
export const ExternalAlertmanagers = () => {
@ -39,6 +43,8 @@ export const ExternalAlertmanagers = () => {
const [deleteModalState, setDeleteModalState] = useState({ open: false, index: 0 });
const externalAlertManagers = useExternalAmSelector();
const externalDsAlertManagers = useExternalDataSourceAlertmanagers();
const alertmanagersChoice = useSelector(
(state: StoreState) => state.unifiedAlerting.externalAlertmanagers.alertmanagerConfig.result?.alertmanagersChoice
);
@ -47,6 +53,7 @@ export const ExternalAlertmanagers = () => {
useEffect(() => {
dispatch(fetchExternalAlertmanagersAction());
dispatch(fetchExternalAlertmanagersConfigAction());
dispatch(loadDataSources());
const interval = setInterval(() => dispatch(fetchExternalAlertmanagersAction()), 5000);
return () => {
@ -63,7 +70,10 @@ export const ExternalAlertmanagers = () => {
return am.url;
});
dispatch(
addExternalAlertmanagersAction({ alertmanagers: newList, alertmanagersChoice: alertmanagersChoice ?? 'all' })
addExternalAlertmanagersAction({
alertmanagers: newList,
alertmanagersChoice: alertmanagersChoice ?? AlertmanagerChoice.All,
})
);
setDeleteModalState({ open: false, index: 0 });
},
@ -97,14 +107,19 @@ export const ExternalAlertmanagers = () => {
}));
}, [setModalState]);
const onChangeAlertmanagerChoice = (alertmanagersChoice: string) => {
const onChangeAlertmanagerChoice = (alertmanagersChoice: AlertmanagerChoice) => {
dispatch(
addExternalAlertmanagersAction({ alertmanagers: externalAlertManagers.map((am) => am.url), alertmanagersChoice })
);
};
const onChangeAlertmanagers = (alertmanagers: string[]) => {
dispatch(addExternalAlertmanagersAction({ alertmanagers, alertmanagersChoice: alertmanagersChoice ?? 'all' }));
dispatch(
addExternalAlertmanagersAction({
alertmanagers,
alertmanagersChoice: alertmanagersChoice ?? AlertmanagerChoice.All,
})
);
};
const getStatusColor = (status: string) => {
@ -121,10 +136,47 @@ export const ExternalAlertmanagers = () => {
};
const noAlertmanagers = externalAlertManagers?.length === 0;
const noDsAlertmanagers = externalDsAlertManagers?.length === 0;
const hasExternalAlertmanagers = !(noAlertmanagers && noDsAlertmanagers);
return (
<div>
<h4>External Alertmanagers</h4>
<Alert title="External Alertmanager changes" severity="info">
The way you configure external Alertmanagers has changed.
<br />
You can now use configured Alertmanager data sources as receivers of your Grafana-managed alerts.
<br />
For more information, refer to our documentation.
</Alert>
<ExternalAlertmanagerDataSources
alertmanagers={externalDsAlertManagers}
inactive={alertmanagersChoice === AlertmanagerChoice.Internal}
/>
{hasExternalAlertmanagers && (
<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."
>
<RadioButtonGroup
options={alertmanagerChoices}
value={alertmanagersChoice}
onChange={(value) => onChangeAlertmanagerChoice(value!)}
/>
</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.
@ -136,6 +188,7 @@ export const ExternalAlertmanagers = () => {
</Button>
)}
</div>
{noAlertmanagers ? (
<EmptyListCTA
title="You have not added any external alertmanagers"
@ -188,20 +241,9 @@ export const ExternalAlertmanagers = () => {
})}
</tbody>
</table>
<div>
<Field
label="Send alerts to"
description="Sets which Alertmanager will handle your alerts. Internal (Grafana built in Alertmanager), External (All Alertmanagers configured above), or both."
>
<RadioButtonGroup
options={alertmanagerChoices}
value={alertmanagersChoice}
onChange={(value) => onChangeAlertmanagerChoice(value!)}
/>
</Field>
</div>
</>
)}
<ConfirmModal
isOpen={deleteModalState.open}
title="Remove Alertmanager"
@ -221,7 +263,7 @@ export const ExternalAlertmanagers = () => {
);
};
const getStyles = (theme: GrafanaTheme2) => ({
export const getStyles = (theme: GrafanaTheme2) => ({
url: css`
margin-right: ${theme.spacing(1)};
`,
@ -236,4 +278,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
table: css`
margin-bottom: ${theme.spacing(2)};
`,
amChoice: css`
margin-bottom: ${theme.spacing(4)};
`,
});

@ -9,6 +9,7 @@ import { Alert, Button, Tooltip, useStyles2 } from '@grafana/ui';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { getRulesDataSources, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { makeDataSourceLink } from '../../utils/misc';
import { isRulerNotSupportedResponse } from '../../utils/rules';
export function RuleListErrors(): ReactElement {
@ -52,7 +53,7 @@ export function RuleListErrors(): ReactElement {
result.push(
<>
Failed to load the data source configuration for{' '}
<a href={`datasources/edit/${dataSource.uid}`}>{dataSource.name}</a>: {error.message || 'Unknown error.'}
<a href={makeDataSourceLink(dataSource)}>{dataSource.name}</a>: {error.message || 'Unknown error.'}
</>
);
});
@ -60,7 +61,7 @@ export function RuleListErrors(): ReactElement {
promRequestErrors.forEach(({ dataSource, error }) =>
result.push(
<>
Failed to load rules state from <a href={`datasources/edit/${dataSource.uid}`}>{dataSource.name}</a>:{' '}
Failed to load rules state from <a href={makeDataSourceLink(dataSource)}>{dataSource.name}</a>:{' '}
{error.message || 'Unknown error.'}
</>
)
@ -69,7 +70,7 @@ export function RuleListErrors(): ReactElement {
rulerRequestErrors.forEach(({ dataSource, error }) =>
result.push(
<>
Failed to load rules config from <a href={`datasources/edit/${dataSource.uid}`}>{dataSource.name}</a>:{' '}
Failed to load rules config from <a href={makeDataSourceLink(dataSource)}>{dataSource.name}</a>:{' '}
{error.message || 'Unknown error.'}
</>
)

@ -1,167 +0,0 @@
import * as reactRedux from 'react-redux';
import { useExternalAmSelector } from './useExternalAmSelector';
const createMockStoreState = (
activeAlertmanagers: Array<{ url: string }>,
droppedAlertmanagers: Array<{ url: string }>,
alertmanagerConfig: string[]
) => ({
unifiedAlerting: {
externalAlertmanagers: {
discoveredAlertmanagers: {
result: {
data: {
activeAlertManagers: activeAlertmanagers,
droppedAlertManagers: droppedAlertmanagers,
},
},
},
alertmanagerConfig: {
result: {
alertmanagers: alertmanagerConfig,
},
},
},
},
});
describe('useExternalAmSelector', () => {
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
beforeEach(() => {
useSelectorMock.mockClear();
});
it('should have one in pending', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(createMockStoreState([], [], ['some/url/to/am']));
});
const alertmanagers = useExternalAmSelector();
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
status: 'pending',
actualUrl: '',
},
]);
});
it('should have one active, one pending', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState([{ url: 'some/url/to/am/api/v2/alerts' }], [], ['some/url/to/am', 'some/url/to/am1'])
);
});
const alertmanagers = useExternalAmSelector();
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', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState(
[{ url: 'some/url/to/am/api/v2/alerts' }, { url: 'some/url/to/am1/api/v2/alerts' }],
[],
['some/url/to/am', 'some/url/to/am1']
)
);
});
const alertmanagers = useExternalAmSelector();
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', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState(
[{ url: 'some/url/to/am/api/v2/alerts' }],
[{ url: 'some/dropped/url/api/v2/alerts' }],
['some/url/to/am', 'some/url/to/am1']
)
);
});
const alertmanagers = useExternalAmSelector();
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', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState(
[
{ url: 'same/url/to/am/api/v2/alerts' },
{ url: 'same/url/to/am/api/v2/alerts' },
{ url: 'same/url/to/am/api/v2/alerts' },
],
[],
['same/url/to/am', 'same/url/to/am', 'same/url/to/am']
)
);
});
const alertmanagers = useExternalAmSelector();
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',
},
]);
});
});

@ -0,0 +1,416 @@
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import * as reactRedux from 'react-redux';
import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AlertmanagerChoice, AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { mockDataSource, mockDataSourcesStore, mockStore } from '../mocks';
import { useExternalAmSelector, useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
describe('useExternalAmSelector', () => {
beforeEach(() => {
useSelectorMock.mockClear();
});
it('should have one in pending', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(createMockStoreState([], [], ['some/url/to/am']));
});
const alertmanagers = useExternalAmSelector();
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
status: 'pending',
actualUrl: '',
},
]);
});
it('should have one active, one pending', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState([{ url: 'some/url/to/am/api/v2/alerts' }], [], ['some/url/to/am', 'some/url/to/am1'])
);
});
const alertmanagers = useExternalAmSelector();
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', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState(
[{ url: 'some/url/to/am/api/v2/alerts' }, { url: 'some/url/to/am1/api/v2/alerts' }],
[],
['some/url/to/am', 'some/url/to/am1']
)
);
});
const alertmanagers = useExternalAmSelector();
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', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState(
[{ url: 'some/url/to/am/api/v2/alerts' }],
[{ url: 'some/dropped/url/api/v2/alerts' }],
['some/url/to/am', 'some/url/to/am1']
)
);
});
const alertmanagers = useExternalAmSelector();
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', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState(
[
{ url: 'same/url/to/am/api/v2/alerts' },
{ url: 'same/url/to/am/api/v2/alerts' },
{ url: 'same/url/to/am/api/v2/alerts' },
],
[],
['same/url/to/am', 'same/url/to/am', 'same/url/to/am']
)
);
});
const alertmanagers = useExternalAmSelector();
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', () => {
beforeEach(() => {
useSelectorMock.mockRestore();
});
it('Should merge data sources information from config and api responses', () => {
// Arrange
const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' });
config.datasources = {
'External Alertmanager': dsInstanceSettings,
};
const store = mockDataSourcesStore({
dataSources: [dsSettings],
});
const wrapper: React.FC = ({ children }) => <reactRedux.Provider store={store}>{children}</reactRedux.Provider>;
// Act
const {
result: { current },
} = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper });
// Assert
expect(current).toHaveLength(1);
expect(current[0].dataSource.uid).toBe('1');
expect(current[0].url).toBe('http://grafana.com');
});
it('Should have active state if available in the activeAlertManagers', () => {
// Arrange
const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' });
config.datasources = {
'External Alertmanager': dsInstanceSettings,
};
const store = mockStore((state) => {
state.dataSources.dataSources = [dsSettings];
state.unifiedAlerting.externalAlertmanagers.discoveredAlertmanagers.result = {
data: {
activeAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }],
droppedAlertManagers: [],
},
};
});
const wrapper: React.FC = ({ children }) => <reactRedux.Provider store={store}>{children}</reactRedux.Provider>;
// Act
const {
result: { current },
} = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper });
// Assert
expect(current).toHaveLength(1);
expect(current[0].status).toBe('active');
expect(current[0].statusInconclusive).toBe(false);
});
it('Should have dropped state if available in the droppedAlertManagers', () => {
// Arrange
const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' });
config.datasources = {
'External Alertmanager': dsInstanceSettings,
};
const store = mockStore((state) => {
state.dataSources.dataSources = [dsSettings];
state.unifiedAlerting.externalAlertmanagers.discoveredAlertmanagers.result = {
data: {
activeAlertManagers: [],
droppedAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }],
},
};
});
const wrapper: React.FC = ({ children }) => <reactRedux.Provider store={store}>{children}</reactRedux.Provider>;
// Act
const {
result: { current },
} = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper });
// Assert
expect(current).toHaveLength(1);
expect(current[0].status).toBe('dropped');
expect(current[0].statusInconclusive).toBe(false);
});
it('Should have pending state if not available neither in dropped nor in active alertManagers', () => {
// Arrange
const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource();
config.datasources = {
'External Alertmanager': dsInstanceSettings,
};
const store = mockStore((state) => {
state.dataSources.dataSources = [dsSettings];
state.unifiedAlerting.externalAlertmanagers.discoveredAlertmanagers.result = {
data: {
activeAlertManagers: [],
droppedAlertManagers: [],
},
};
});
const wrapper: React.FC = ({ children }) => <reactRedux.Provider store={store}>{children}</reactRedux.Provider>;
// Act
const {
result: { current },
} = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper });
// Assert
expect(current).toHaveLength(1);
expect(current[0].status).toBe('pending');
expect(current[0].statusInconclusive).toBe(false);
});
it('Should match Alertmanager url when datasource url does not have protocol specified', () => {
// Arrange
const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'localhost:9093' });
config.datasources = {
'External Alertmanager': dsInstanceSettings,
};
const store = mockStore((state) => {
state.dataSources.dataSources = [dsSettings];
state.unifiedAlerting.externalAlertmanagers.discoveredAlertmanagers.result = {
data: {
activeAlertManagers: [{ url: 'http://localhost:9093/api/v2/alerts' }],
droppedAlertManagers: [],
},
};
});
const wrapper: React.FC = ({ children }) => <reactRedux.Provider store={store}>{children}</reactRedux.Provider>;
// Act
const {
result: { current },
} = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper });
// Assert
expect(current).toHaveLength(1);
expect(current[0].status).toBe('active');
expect(current[0].url).toBe('localhost:9093');
});
it('Should have inconclusive state when there are many Alertmanagers of the same URL', () => {
// Arrange
const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' });
config.datasources = {
'External Alertmanager': dsInstanceSettings,
};
const store = mockStore((state) => {
state.dataSources.dataSources = [dsSettings];
state.unifiedAlerting.externalAlertmanagers.discoveredAlertmanagers.result = {
data: {
activeAlertManagers: [
{ url: 'http://grafana.com/api/v2/alerts' },
{ url: 'http://grafana.com/api/v2/alerts' },
],
droppedAlertManagers: [],
},
};
});
const wrapper: React.FC = ({ children }) => <reactRedux.Provider store={store}>{children}</reactRedux.Provider>;
// Act
const {
result: { current },
} = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper });
// Assert
expect(current).toHaveLength(1);
expect(current[0].status).toBe('active');
expect(current[0].statusInconclusive).toBe(true);
});
});
function setupAlertmanagerDataSource(partialDsSettings?: Partial<DataSourceSettings<AlertManagerDataSourceJsonData>>) {
const dsCommonConfig = {
uid: '1',
name: 'External Alertmanager',
type: 'alertmanager',
jsonData: { handleGrafanaManagedAlerts: true } as AlertManagerDataSourceJsonData,
};
const dsInstanceSettings = mockDataSource(dsCommonConfig);
const dsSettings = mockApiDataSource({
...dsCommonConfig,
...partialDsSettings,
});
return { dsSettings, dsInstanceSettings };
}
function mockApiDataSource(partial: Partial<DataSourceSettings<DataSourceJsonData, {}>> = {}) {
const dsSettings: DataSourceSettings<DataSourceJsonData, {}> = {
uid: '1',
id: 1,
name: '',
url: '',
type: '',
access: '',
orgId: 1,
typeLogoUrl: '',
typeName: '',
user: '',
database: '',
basicAuth: false,
isDefault: false,
basicAuthUser: '',
jsonData: { handleGrafanaManagedAlerts: true } as AlertManagerDataSourceJsonData,
secureJsonFields: {},
readOnly: false,
withCredentials: false,
...partial,
};
return dsSettings;
}
const createMockStoreState = (
activeAlertmanagers: Array<{ url: string }>,
droppedAlertmanagers: Array<{ url: string }>,
alertmanagerConfig: string[]
) => {
return {
unifiedAlerting: {
externalAlertmanagers: {
discoveredAlertmanagers: {
result: {
data: {
activeAlertManagers: activeAlertmanagers,
droppedAlertManagers: droppedAlertmanagers,
},
},
dispatched: false,
loading: false,
},
alertmanagerConfig: {
result: {
alertmanagers: alertmanagerConfig,
alertmanagersChoice: AlertmanagerChoice.All,
},
dispatched: false,
loading: false,
},
},
},
};
};

@ -1,6 +1,13 @@
import { countBy, keyBy } from 'lodash';
import { useSelector } from 'react-redux';
import { DataSourceInstanceSettings, DataSourceSettings } from '@grafana/data';
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { StoreState } from '../../../../types';
import { getAlertManagerDataSources } from '../utils/datasource';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
const SUFFIX_REGEX = /\/api\/v[1|2]\/alerts/i;
type AlertmanagerConfig = { url: string; status: string; actualUrl: string };
@ -51,3 +58,71 @@ export function useExternalAmSelector(): AlertmanagerConfig[] | [] {
return [...enabledAlertmanagers, ...droppedAlertmanagers];
}
export interface ExternalDataSourceAM {
dataSource: DataSourceInstanceSettings<AlertManagerDataSourceJsonData>;
url?: string;
status: 'active' | 'pending' | 'dropped';
statusInconclusive?: boolean;
}
export function useExternalDataSourceAlertmanagers(): ExternalDataSourceAM[] {
const externalDsAlertManagers = getAlertManagerDataSources().filter((ds) => ds.jsonData.handleGrafanaManagedAlerts);
const alertmanagerDatasources = useSelector((state: StoreState) =>
keyBy(
state.dataSources.dataSources.filter((ds) => ds.type === 'alertmanager'),
(ds) => ds.uid
)
);
const discoveredAlertmanagers = useUnifiedAlertingSelector(
(state) => state.externalAlertmanagers.discoveredAlertmanagers.result?.data
);
const droppedAMUrls = countBy(discoveredAlertmanagers?.droppedAlertManagers, (x) => x.url);
const activeAMUrls = countBy(discoveredAlertmanagers?.activeAlertManagers, (x) => x.url);
return externalDsAlertManagers.map<ExternalDataSourceAM>((dsAm) => {
const dsSettings = alertmanagerDatasources[dsAm.uid];
if (!dsSettings) {
return {
dataSource: dsAm,
status: 'pending',
};
}
const amUrl = getDataSourceUrlWithProtocol(dsSettings);
const amStatusUrl = `${amUrl}/api/v2/alerts`;
const matchingDroppedUrls = droppedAMUrls[amStatusUrl] ?? 0;
const matchingActiveUrls = activeAMUrls[amStatusUrl] ?? 0;
const isDropped = matchingDroppedUrls > 0;
const isActive = matchingActiveUrls > 0;
// Multiple Alertmanagers of the same URL may exist (e.g. with different credentials)
// Alertmanager response only contains URLs, so in case of duplication, we are not able
// to distinguish which is which, resulting in an inconclusive status.
const isStatusInconclusive = matchingDroppedUrls + matchingActiveUrls > 1;
const status = isDropped ? 'dropped' : isActive ? 'active' : 'pending';
return {
dataSource: dsAm,
url: dsSettings.url,
status,
statusInconclusive: isStatusInconclusive,
};
});
}
function getDataSourceUrlWithProtocol<T>(dsSettings: DataSourceSettings<T>) {
const hasProtocol = new RegExp('^[^:]*://').test(dsSettings.url);
if (!hasProtocol) {
return `http://${dsSettings.url}`; // Grafana append http protocol if there is no any
}
return dsSettings.url;
}

@ -3,10 +3,16 @@ import React from 'react';
import { Provider } from 'react-redux';
import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction, FolderDTO, StoreState } from 'app/types';
import { disableRBAC, enableRBAC, mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule } from '../mocks';
import {
disableRBAC,
enableRBAC,
mockFolder,
mockRulerAlertingRule,
mockRulerGrafanaRule,
mockUnifiedAlertingStore,
} from '../mocks';
import { useFolder } from './useFolder';
import { useIsRuleEditable } from './useIsRuleEditable';
@ -166,7 +172,7 @@ function mockPermissions(grantedPermissions: AccessControlAction[]) {
function getProviderWrapper() {
const dataSources = getMockedDataSources();
const store = mockStore({ dataSources });
const store = mockUnifiedAlertingStore({ dataSources });
const wrapper: React.FC = ({ children }) => <Provider store={store}>{children}</Provider>;
return wrapper;
}
@ -193,15 +199,3 @@ function getMockedDataSources(): StoreState['unifiedAlerting']['dataSources'] {
},
};
}
function mockStore(unifiedAlerting?: Partial<StoreState['unifiedAlerting']>) {
const defaultState = configureStore().getState();
return configureStore({
...defaultState,
unifiedAlerting: {
...defaultState.unifiedAlerting,
...unifiedAlerting,
},
});
}

@ -1,3 +1,5 @@
import produce from 'immer';
import {
DataSourceApi,
DataSourceInstanceSettings,
@ -19,7 +21,8 @@ import {
Silence,
SilenceState,
} from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction, FolderDTO } from 'app/types';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction, FolderDTO, StoreState } from 'app/types';
import { Alert, AlertingRule, CombinedRule, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting';
import {
GrafanaAlertStateDecision,
@ -480,3 +483,34 @@ export const grantUserPermissions = (permissions: AccessControlAction[]) => {
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((action) => permissions.includes(action as AccessControlAction));
};
export function mockDataSourcesStore(partial?: Partial<StoreState['dataSources']>) {
const defaultState = configureStore().getState();
const store = configureStore({
...defaultState,
dataSources: {
...defaultState.dataSources,
...partial,
},
});
return store;
}
export function mockUnifiedAlertingStore(unifiedAlerting?: Partial<StoreState['unifiedAlerting']>) {
const defaultState = configureStore().getState();
return configureStore({
...defaultState,
unifiedAlerting: {
...defaultState.unifiedAlerting,
...unifiedAlerting,
},
});
}
export function mockStore(recipe: (state: StoreState) => void) {
const defaultState = configureStore().getState();
return configureStore(produce(defaultState, recipe));
}

@ -41,7 +41,9 @@ export function getRulesDataSource(rulesSourceName: string) {
export function getAlertManagerDataSources() {
return getAllDataSources()
.filter((ds) => ds.type === DataSourceType.Alertmanager)
.filter(
(ds): ds is DataSourceInstanceSettings<AlertManagerDataSourceJsonData> => ds.type === DataSourceType.Alertmanager
)
.sort((a, b) => a.name.localeCompare(b.name));
}

@ -1,6 +1,6 @@
import { sortBy } from 'lodash';
import { urlUtil, UrlQueryMap, Labels } from '@grafana/data';
import { urlUtil, UrlQueryMap, Labels, DataSourceInstanceSettings } from '@grafana/data';
import { config } from '@grafana/runtime';
import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules';
import { SortOrder } from 'app/plugins/panel/alertlist/types';
@ -98,6 +98,10 @@ export function makeLabelBasedSilenceLink(alertManagerSourceName: string, labels
return `${config.appSubUrl}/alerting/silence/new?${silenceUrlParams.toString()}`;
}
export function makeDataSourceLink<T>(dataSource: DataSourceInstanceSettings<T>) {
return `${config.appSubUrl}/datasources/edit/${dataSource.uid}`;
}
// keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet
export function retryWhile<T, E = Error>(
fn: () => Promise<T>,

@ -1,15 +1,16 @@
import produce from 'immer';
import React from 'react';
import { SIGV4ConnectionConfig } from '@grafana/aws-sdk';
import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data';
import { DataSourceHttpSettings, InlineFormLabel, Select } from '@grafana/ui';
import { DataSourceHttpSettings, InlineField, InlineFormLabel, InlineSwitch, Select } from '@grafana/ui';
import { config } from 'app/core/config';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types';
export type Props = DataSourcePluginOptionsEditorProps<AlertManagerDataSourceJsonData>;
const IMPL_OPTIONS: SelectableValue[] = [
const IMPL_OPTIONS: Array<SelectableValue<AlertManagerImplementation>> = [
{
value: AlertManagerImplementation.mimir,
icon: 'public/img/alerting/mimir_logo.svg',
@ -48,13 +49,31 @@ export const ConfigEditor = (props: Props) => {
...options,
jsonData: {
...options.jsonData,
implementation: value.value as AlertManagerImplementation,
implementation: value.value,
},
})
}
/>
</div>
</div>
<div className="gf-form-inline">
<InlineField
label="Receive Grafana Alerts"
tooltip="When enabled, Grafana-managed alerts are sent to this Alertmanager."
labelWidth={26}
>
<InlineSwitch
value={options.jsonData.handleGrafanaManagedAlerts ?? false}
onChange={(e) => {
onOptionsChange(
produce(options, (draft) => {
draft.jsonData.handleGrafanaManagedAlerts = e.currentTarget.checked;
})
);
}}
/>
</InlineField>
</div>
</div>
<DataSourceHttpSettings
defaultUrl={''}

@ -277,12 +277,17 @@ export interface AlertmanagerUrl {
export interface ExternalAlertmanagersResponse {
data: ExternalAlertmanagers;
status: 'string';
}
export enum AlertmanagerChoice {
Internal = 'internal',
External = 'external',
All = 'all',
}
export interface ExternalAlertmanagerConfig {
alertmanagers: string[];
alertmanagersChoice: string;
alertmanagersChoice: AlertmanagerChoice;
}
export enum AlertManagerImplementation {
@ -310,4 +315,7 @@ export type MuteTimeInterval = {
provenance?: string;
};
export type AlertManagerDataSourceJsonData = DataSourceJsonData & { implementation?: AlertManagerImplementation };
export interface AlertManagerDataSourceJsonData extends DataSourceJsonData {
implementation?: AlertManagerImplementation;
handleGrafanaManagedAlerts?: boolean;
}

Loading…
Cancel
Save