diff --git a/.betterer.results b/.betterer.results
index 871afc69376..6bff61be33c 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -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"]
],
diff --git a/pkg/services/ngalert/sender/router.go b/pkg/services/ngalert/sender/router.go
index 120f418a030..a897f68656c 100644
--- a/pkg/services/ngalert/sender/router.go
+++ b/pkg/services/ngalert/sender/router.go
@@ -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
- // if basic auth is enabled we need to build the url with basic auth baked in
- if !ds.BasicAuth {
- return amURL, nil
- }
-
- parsed, err := url.Parse(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 parsed.String(), nil
+ }
+
password := d.secretService.GetDecryptedValue(context.Background(), ds.SecureJsonData, "basicAuthPassword", "")
if password == "" {
return "", fmt.Errorf("basic auth enabled but no password set")
diff --git a/pkg/services/ngalert/sender/router_test.go b/pkg/services/ngalert/sender/router_test.go
index bdc8b02ee69..68b934c36da 100644
--- a/pkg/services/ngalert/sender/router_test.go
+++ b/pkg/services/ngalert/sender/router_test.go
@@ -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) {
diff --git a/public/app/features/alerting/unified/Admin.tsx b/public/app/features/alerting/unified/Admin.tsx
index 28a2a518763..783b5c60cf1 100644
--- a/public/app/features/alerting/unified/Admin.tsx
+++ b/public/app/features/alerting/unified/Admin.tsx
@@ -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 (
-
+ {isGrafanaAmSelected && }
);
}
diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx
new file mode 100644
index 00000000000..a0325ae9487
--- /dev/null
+++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx
@@ -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 (
+ <>
+
Alertmanagers data sources
+
+ Alertmanager data sources support a configuration setting that allows you to choose to send Grafana-managed
+ alerts to that Alertmanager.
+ Below, you can see the list of all Alertmanager data sources that have this setting enabled.
+
+ {alertmanagers.length === 0 && (
+
+ There are no Alertmanager data sources configured to receive Grafana-managed alerts.
+ You can change this by selecting Receive Grafana Alerts in a data source configuration.
+
+ }
+ callToActionElement={Go to data sources}
+ className={styles.externalDsCTA}
+ />
+ )}
+ {alertmanagers.length > 0 && (
+
+ {alertmanagers.map((am) => (
+
+ ))}
+
+ )}
+ >
+ );
+}
+
+interface ExternalAMdataSourceCardProps {
+ alertmanager: ExternalDataSourceAM;
+ inactive: boolean;
+}
+
+export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMdataSourceCardProps) {
+ const styles = useStyles2(getStyles);
+
+ const { dataSource, status, statusInconclusive, url } = alertmanager;
+
+ return (
+
+
+ {dataSource.name}{' '}
+ {statusInconclusive && (
+
+
+
+ )}
+
+
+
+
+
+ {inactive ? (
+
+ ) : (
+
+ )}
+
+ {url}
+
+
+ Go to datasouce
+
+
+
+ );
+}
+
+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)};
+ `,
+});
diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
index f98aff36b96..3321b48f13d 100644
--- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
+++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
@@ -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> = [
+ { 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 (
External Alertmanagers
+
+ The way you configure external Alertmanagers has changed.
+
+ You can now use configured Alertmanager data sources as receivers of your Grafana-managed alerts.
+
+ For more information, refer to our documentation.
+
+
+
+
+ {hasExternalAlertmanagers && (
+
+
+ onChangeAlertmanagerChoice(value!)}
+ />
+
+
+ )}
+
+
Alertmanagers by URL
+
+ The URL-based configuration of Alertmanagers is deprecated and will be removed in Grafana 9.2.0.
+
+ Use Alertmanager data sources to configure your external Alertmanagers.
+
+
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 = () => {
)}
+
{noAlertmanagers ? (
{
})}
-
-
- onChangeAlertmanagerChoice(value!)}
- />
-
-
>
)}
+
{
);
};
-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)};
+ `,
});
diff --git a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx
index 2076fa92409..6899ca489cc 100644
--- a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx
@@ -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{' '}
- {dataSource.name}: {error.message || 'Unknown error.'}
+ {dataSource.name}: {error.message || 'Unknown error.'}
>
);
});
@@ -60,7 +61,7 @@ export function RuleListErrors(): ReactElement {
promRequestErrors.forEach(({ dataSource, error }) =>
result.push(
<>
- Failed to load rules state from {dataSource.name}:{' '}
+ Failed to load rules state from {dataSource.name}:{' '}
{error.message || 'Unknown error.'}
>
)
@@ -69,7 +70,7 @@ export function RuleListErrors(): ReactElement {
rulerRequestErrors.forEach(({ dataSource, error }) =>
result.push(
<>
- Failed to load rules config from {dataSource.name}:{' '}
+ Failed to load rules config from {dataSource.name}:{' '}
{error.message || 'Unknown error.'}
>
)
diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.ts b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.ts
deleted file mode 100644
index 2a30a8a757b..00000000000
--- a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.ts
+++ /dev/null
@@ -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',
- },
- ]);
- });
-});
diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx
new file mode 100644
index 00000000000..102560fa9e0
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx
@@ -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 }) => {children};
+
+ // 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 }) => {children};
+
+ // 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 }) => {children};
+
+ // 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 }) => {children};
+
+ // 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 }) => {children};
+
+ // 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 }) => {children};
+
+ // 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>) {
+ 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> = {}) {
+ const dsSettings: DataSourceSettings = {
+ 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,
+ },
+ },
+ },
+ };
+};
diff --git a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts
index 51a58075a13..95432dc933e 100644
--- a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts
+++ b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts
@@ -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;
+ 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((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(dsSettings: DataSourceSettings) {
+ 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;
+}
diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
index d910796eb0b..369006b7026 100644
--- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
+++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
@@ -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 }) => {children};
return wrapper;
}
@@ -193,15 +199,3 @@ function getMockedDataSources(): StoreState['unifiedAlerting']['dataSources'] {
},
};
}
-
-function mockStore(unifiedAlerting?: Partial) {
- const defaultState = configureStore().getState();
-
- return configureStore({
- ...defaultState,
- unifiedAlerting: {
- ...defaultState.unifiedAlerting,
- ...unifiedAlerting,
- },
- });
-}
diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts
index 2741a17c284..594bde43aaa 100644
--- a/public/app/features/alerting/unified/mocks.ts
+++ b/public/app/features/alerting/unified/mocks.ts
@@ -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) {
+ const defaultState = configureStore().getState();
+ const store = configureStore({
+ ...defaultState,
+ dataSources: {
+ ...defaultState.dataSources,
+ ...partial,
+ },
+ });
+
+ return store;
+}
+
+export function mockUnifiedAlertingStore(unifiedAlerting?: Partial) {
+ 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));
+}
diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts
index 88c3aa77b6e..733c6332677 100644
--- a/public/app/features/alerting/unified/utils/datasource.ts
+++ b/public/app/features/alerting/unified/utils/datasource.ts
@@ -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 => ds.type === DataSourceType.Alertmanager
+ )
.sort((a, b) => a.name.localeCompare(b.name));
}
diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts
index c04af6cb423..288f91d11a3 100644
--- a/public/app/features/alerting/unified/utils/misc.ts
+++ b/public/app/features/alerting/unified/utils/misc.ts
@@ -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(dataSource: DataSourceInstanceSettings) {
+ 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(
fn: () => Promise,
diff --git a/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx b/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
index 6458553f338..d80840b560d 100644
--- a/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
+++ b/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
@@ -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;
-const IMPL_OPTIONS: SelectableValue[] = [
+const IMPL_OPTIONS: Array> = [
{
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,
},
})
}
/>
+
+
+ {
+ onOptionsChange(
+ produce(options, (draft) => {
+ draft.jsonData.handleGrafanaManagedAlerts = e.currentTarget.checked;
+ })
+ );
+ }}
+ />
+
+