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; + }) + ); + }} + /> + +