From 7f6806e2202efb0f8404291416f3f0feea111b13 Mon Sep 17 00:00:00 2001 From: Gilles De Mey <gilles.de.mey@gmail.com> Date: Thu, 1 Feb 2024 14:18:43 +0100 Subject: [PATCH] Alerting: Refactor `useExternalDataSourceAlertmanagers` (#81081) --- .../alerting/unified/api/alertingApi.ts | 8 +- .../alerting/unified/api/dataSourcesApi.ts | 15 ++ .../admin/ExternalAlertmanagerDataSources.tsx | 18 +- .../admin/ExternalAlertmanagers.tsx | 5 +- .../components/rules/RuleListErrors.tsx | 6 +- .../hooks/useExternalAMSelector.test.tsx | 196 +++++++----------- .../unified/hooks/useExternalAmSelector.ts | 152 +++++++++----- public/app/features/alerting/unified/mocks.ts | 13 -- .../features/alerting/unified/utils/config.ts | 5 +- .../alerting/unified/utils/datasource.ts | 25 ++- .../features/alerting/unified/utils/misc.ts | 6 +- 11 files changed, 226 insertions(+), 223 deletions(-) create mode 100644 public/app/features/alerting/unified/api/dataSourcesApi.ts diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index 2ec7fc1d5c6..d4f9118395e 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -27,6 +27,12 @@ export const backendSrvBaseQuery = (): BaseQueryFn<BackendSrvRequest> => async ( export const alertingApi = createApi({ reducerPath: 'alertingApi', baseQuery: backendSrvBaseQuery(), - tagTypes: ['AlertmanagerChoice', 'AlertmanagerConfiguration', 'OnCallIntegrations', 'OrgMigrationState'], + tagTypes: [ + 'AlertmanagerChoice', + 'AlertmanagerConfiguration', + 'OnCallIntegrations', + 'OrgMigrationState', + 'DataSourceSettings', + ], endpoints: () => ({}), }); diff --git a/public/app/features/alerting/unified/api/dataSourcesApi.ts b/public/app/features/alerting/unified/api/dataSourcesApi.ts new file mode 100644 index 00000000000..94bb2de0937 --- /dev/null +++ b/public/app/features/alerting/unified/api/dataSourcesApi.ts @@ -0,0 +1,15 @@ +import { DataSourceJsonData, DataSourceSettings } from '@grafana/data'; + +import { alertingApi } from './alertingApi'; + +export const dataSourcesApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + getAllDataSourceSettings: build.query<Array<DataSourceSettings<DataSourceJsonData>>, void>({ + query: () => ({ url: 'api/datasources' }), + // we'll create individual cache entries for each datasource UID + providesTags: (result) => { + return result ? result.map(({ uid }) => ({ type: 'DataSourceSettings', id: uid })) : ['DataSourceSettings']; + }, + }), + }), +}); diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx index d84f930d3cc..757346b7976 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx @@ -5,11 +5,11 @@ 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 { ExternalAlertmanagerDataSourceWithStatus } from '../../hooks/useExternalAmSelector'; import { makeDataSourceLink } from '../../utils/misc'; export interface ExternalAlertManagerDataSourcesProps { - alertmanagers: ExternalDataSourceAM[]; + alertmanagers: ExternalAlertmanagerDataSourceWithStatus[]; inactive: boolean; } @@ -39,7 +39,7 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext {alertmanagers.length > 0 && ( <div className={styles.externalDs}> {alertmanagers.map((am) => ( - <ExternalAMdataSourceCard key={am.dataSource.uid} alertmanager={am} inactive={inactive} /> + <ExternalAMdataSourceCard key={am.dataSourceSettings.uid} alertmanager={am} inactive={inactive} /> ))} </div> )} @@ -48,20 +48,20 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext } interface ExternalAMdataSourceCardProps { - alertmanager: ExternalDataSourceAM; + alertmanager: ExternalAlertmanagerDataSourceWithStatus; inactive: boolean; } export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMdataSourceCardProps) { const styles = useStyles2(getStyles); - const { dataSource, status, statusInconclusive, url } = alertmanager; + const { dataSourceSettings, status } = alertmanager; return ( <Card> <Card.Heading className={styles.externalHeading}> - {dataSource.name}{' '} - {statusInconclusive && ( + {dataSourceSettings.name}{' '} + {status === 'inconclusive' && ( <Tooltip content="Multiple Alertmanagers have the same URL configured. The state might be inconclusive."> <Icon name="exclamation-triangle" size="md" className={styles.externalWarningIcon} /> </Tooltip> @@ -90,9 +90,9 @@ export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMd /> )} </Card.Tags> - <Card.Meta>{url}</Card.Meta> + <Card.Meta>{dataSourceSettings.url}</Card.Meta> <Card.Actions> - <LinkButton href={makeDataSourceLink(dataSource)} size="sm" variant="secondary"> + <LinkButton href={makeDataSourceLink(dataSourceSettings.uid)} size="sm" variant="secondary"> Go to datasource </LinkButton> </Card.Actions> diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx index 1bfb98fe018..ddcf5b9176d 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx @@ -23,6 +23,9 @@ export const ExternalAlertmanagers = () => { const dispatch = useDispatch(); const externalDsAlertManagers = useExternalDataSourceAlertmanagers(); + const gmaHandlingAlertmanagers = externalDsAlertManagers.filter( + (settings) => settings.dataSourceSettings.jsonData.handleGrafanaManagedAlerts === true + ); const { useSaveExternalAlertmanagersConfigMutation, @@ -71,7 +74,7 @@ export const ExternalAlertmanagers = () => { </div> <ExternalAlertmanagerDataSources - alertmanagers={externalDsAlertManagers} + alertmanagers={gmaHandlingAlertmanagers} inactive={alertmanagersChoice === AlertmanagerChoice.Internal} /> </div> diff --git a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx index c595014ac96..16da99ee314 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx @@ -53,7 +53,7 @@ export function RuleListErrors(): ReactElement { result.push( <> Failed to load the data source configuration for{' '} - <a href={makeDataSourceLink(dataSource)} className={styles.dsLink}> + <a href={makeDataSourceLink(dataSource.uid)} className={styles.dsLink}> {dataSource.name} </a> : {error.message || 'Unknown error.'} @@ -65,7 +65,7 @@ export function RuleListErrors(): ReactElement { result.push( <> Failed to load rules state from{' '} - <a href={makeDataSourceLink(dataSource)} className={styles.dsLink}> + <a href={makeDataSourceLink(dataSource.uid)} className={styles.dsLink}> {dataSource.name} </a> : {error.message || 'Unknown error.'} @@ -77,7 +77,7 @@ export function RuleListErrors(): ReactElement { result.push( <> Failed to load rules config from{' '} - <a href={makeDataSourceLink(dataSource)} className={styles.dsLink}> + <a href={makeDataSourceLink(dataSource.uid)} className={styles.dsLink}> {dataSource.name} </a> : {error.message || 'Unknown error.'} diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx index f45b2bc0ffe..6feda94a91c 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx +++ b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx @@ -1,16 +1,15 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { Provider } from 'react-redux'; +import { rest } from 'msw'; +import { SetupServer, setupServer } from 'msw/node'; +import { TestProvider } from 'test/helpers/TestProvider'; import 'whatwg-fetch'; -import { DataSourceJsonData, DataSourceSettings } from '@grafana/data'; -import { config, setBackendSrv } from '@grafana/runtime'; +import { DataSourceSettings } from '@grafana/data'; +import { setBackendSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; -import { mockDataSource, mockDataSourcesStore, mockStore } from '../mocks'; import { mockAlertmanagersResponse } from '../mocks/alertmanagerApi'; import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector'; @@ -31,46 +30,42 @@ afterAll(() => { }); describe('useExternalDataSourceAlertmanagers', () => { - it('Should merge data sources information from config and api responses', async () => { + it('Should get the correct data source settings', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' }); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockDataSourcesStore({ - dataSources: [dsSettings], - }); - + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [], droppedAlertManagers: [] } }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert const { current } = result; expect(current).toHaveLength(1); - expect(current[0].dataSource.uid).toBe('1'); - expect(current[0].url).toBe('http://grafana.com'); + expect(current[0].dataSourceSettings.uid).toBe('1'); + expect(current[0].dataSourceSettings.url).toBe('http://grafana.com'); + }); + }); + + it('Should have uninterested state if data source does not want alerts', async () => { + // Arrange + setupAlertmanagerDataSource(server, { url: 'http://grafana.com', jsonData: { handleGrafanaManagedAlerts: false } }); + mockAlertmanagersResponse(server, { data: { activeAlertManagers: [], droppedAlertManagers: [] } }); + + // Act + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); + await waitFor(() => { + // Assert + const { current } = result; + + expect(current).toHaveLength(1); + expect(current[0].status).toBe('uninterested'); }); }); it('Should have active state if available in the activeAlertManagers', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' }); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }], @@ -78,32 +73,20 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert const { current } = result; 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', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' }); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [], @@ -111,10 +94,8 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert @@ -122,22 +103,12 @@ describe('useExternalDataSourceAlertmanagers', () => { 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', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource(); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - + setupAlertmanagerDataSource(server); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [], @@ -145,10 +116,8 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert @@ -156,22 +125,12 @@ describe('useExternalDataSourceAlertmanagers', () => { 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', async () => { // Arrange - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'localhost:9093' }); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - + setupAlertmanagerDataSource(server, { url: 'localhost:9093' }); mockAlertmanagersResponse(server, { data: { activeAlertManagers: [{ url: 'http://localhost:9093/api/v2/alerts' }], @@ -179,10 +138,8 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; - // Act - const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper: TestProvider }); await waitFor(() => { // Assert @@ -190,11 +147,34 @@ describe('useExternalDataSourceAlertmanagers', () => { expect(current).toHaveLength(1); expect(current[0].status).toBe('active'); - expect(current[0].url).toBe('localhost:9093'); + expect(current[0].dataSourceSettings.url).toBe('localhost:9093'); }); }); - it('Should have inconclusive state when there are many Alertmanagers of the same URL', async () => { + it('Should have inconclusive state when there are many Alertmanagers of the same URL on both active and inactive', async () => { + // Arrange + mockAlertmanagersResponse(server, { + data: { + activeAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }], + droppedAlertManagers: [{ url: 'http://grafana.com/api/v2/alerts' }], + }, + }); + + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); + + // Act + const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { + wrapper: TestProvider, + }); + + await waitFor(() => { + // Assert + expect(result.current).toHaveLength(1); + expect(result.current[0].status).toBe('inconclusive'); + }); + }); + + it('Should have not have inconclusive state when all Alertmanagers of the same URL are active', async () => { // Arrange mockAlertmanagersResponse(server, { data: { @@ -203,72 +183,40 @@ describe('useExternalDataSourceAlertmanagers', () => { }, }); - const { dsSettings, dsInstanceSettings } = setupAlertmanagerDataSource({ url: 'http://grafana.com' }); - - config.datasources = { - 'External Alertmanager': dsInstanceSettings, - }; - - const store = mockStore((state) => { - state.dataSources.dataSources = [dsSettings]; - }); - - const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; + setupAlertmanagerDataSource(server, { url: 'http://grafana.com' }); // Act const { result } = renderHook(() => useExternalDataSourceAlertmanagers(), { - wrapper, + wrapper: TestProvider, }); await waitFor(() => { // Assert expect(result.current).toHaveLength(1); expect(result.current[0].status).toBe('active'); - expect(result.current[0].statusInconclusive).toBe(true); }); }); }); -function setupAlertmanagerDataSource(partialDsSettings?: Partial<DataSourceSettings<AlertManagerDataSourceJsonData>>) { +function setupAlertmanagerDataSource( + server: SetupServer, + partialDsSettings?: Partial<DataSourceSettings<AlertManagerDataSourceJsonData>> +) { const dsCommonConfig = { uid: '1', name: 'External Alertmanager', type: 'alertmanager', - jsonData: { handleGrafanaManagedAlerts: true } as AlertManagerDataSourceJsonData, + jsonData: { handleGrafanaManagedAlerts: true }, }; - const dsInstanceSettings = mockDataSource(dsCommonConfig); - - const dsSettings = mockApiDataSource({ + const dsSettings = { ...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; + server.use( + rest.get('/api/datasources', (_req, res, ctx) => { + return res(ctx.json([dsSettings])); + }) + ); } diff --git a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts index 8ffe85bb8cb..8cbe7228698 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts +++ b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts @@ -1,76 +1,114 @@ -import { countBy, keyBy } from 'lodash'; -import { createSelector } from 'reselect'; - -import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceSettings } from '@grafana/data'; -import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; -import { StoreState, useSelector } from 'app/types'; +import { DataSourceSettings } from '@grafana/data'; +import { AlertManagerDataSourceJsonData, ExternalAlertmanagers } from 'app/plugins/datasource/alertmanager/types'; import { alertmanagerApi } from '../api/alertmanagerApi'; -import { getAlertManagerDataSources } from '../utils/datasource'; +import { dataSourcesApi } from '../api/dataSourcesApi'; +import { isAlertmanagerDataSource } from '../utils/datasource'; -export interface ExternalDataSourceAM { - dataSource: DataSourceInstanceSettings<AlertManagerDataSourceJsonData>; - url?: string; - status: 'active' | 'pending' | 'dropped'; - statusInconclusive?: boolean; +type ConnectionStatus = 'active' | 'pending' | 'dropped' | 'inconclusive' | 'uninterested' | 'unknown'; + +export interface ExternalAlertmanagerDataSourceWithStatus { + dataSourceSettings: DataSourceSettings<AlertManagerDataSourceJsonData>; + status: ConnectionStatus; } -export function useExternalDataSourceAlertmanagers(): ExternalDataSourceAM[] { - const { useGetExternalAlertmanagersQuery } = alertmanagerApi; - const { currentData: discoveredAlertmanagers } = useGetExternalAlertmanagersQuery(); +/** + * Returns all configured Alertmanager data sources and their connection status with the internal ruler + */ +export function useExternalDataSourceAlertmanagers(): ExternalAlertmanagerDataSourceWithStatus[] { + // firstly we'll fetch the settings for all datasources and filter for "alertmanager" type + const { alertmanagerDataSources } = dataSourcesApi.endpoints.getAllDataSourceSettings.useQuery(undefined, { + refetchOnReconnect: true, + selectFromResult: (result) => { + const alertmanagerDataSources = result.currentData?.filter(isAlertmanagerDataSource) ?? []; + return { ...result, alertmanagerDataSources }; + }, + }); - const externalDsAlertManagers = getAlertManagerDataSources().filter((ds) => ds.jsonData.handleGrafanaManagedAlerts); - - const alertmanagerDatasources = useSelector( - createSelector( - (state: StoreState) => state.dataSources.dataSources.filter((ds) => ds.type === 'alertmanager'), - (datasources) => keyBy(datasources, (ds) => ds.uid) - ) + // we'll also fetch the configuration for which Alertmanagers we are forwarding Grafana-managed alerts too + // @TODO use polling when we have one or more alertmanagers in pending state + const { currentData: externalAlertmanagers } = alertmanagerApi.endpoints.getExternalAlertmanagers.useQuery( + undefined, + { refetchOnReconnect: true } ); - const droppedAMUrls = countBy(discoveredAlertmanagers?.droppedAlertManagers, (x) => x.url); - const activeAMUrls = countBy(discoveredAlertmanagers?.activeAlertManagers, (x) => x.url); + if (!alertmanagerDataSources) { + return []; + } - 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 alertmanagerDataSources.map<ExternalAlertmanagerDataSourceWithStatus>((dataSourceSettings) => { + const status = externalAlertmanagers + ? determineAlertmanagerConnectionStatus(externalAlertmanagers, dataSourceSettings) + : 'unknown'; return { - dataSource: dsAm, - url: dsSettings.url, + dataSourceSettings, status, - statusInconclusive: isStatusInconclusive, }; }); } -function getDataSourceUrlWithProtocol<T extends DataSourceJsonData>(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 +// using the information from /api/v1/ngalert/alertmanagers we should derive the connection status of a single data source +function determineAlertmanagerConnectionStatus( + externalAlertmanagers: ExternalAlertmanagers, + dataSourceSettings: DataSourceSettings<AlertManagerDataSourceJsonData> +): ConnectionStatus { + const isInterestedInAlerts = dataSourceSettings.jsonData.handleGrafanaManagedAlerts; + if (!isInterestedInAlerts) { + return 'uninterested'; } - return dsSettings.url; + const isActive = + externalAlertmanagers?.activeAlertManagers.some((am) => { + return isAlertmanagerMatchByURL(dataSourceSettings.url, am.url); + }) ?? []; + + const isDropped = + externalAlertmanagers?.droppedAlertManagers.some((am) => { + return isAlertmanagerMatchByURL(dataSourceSettings.url, am.url); + }) ?? []; + + // the Alertmanager is being adopted (pending) if it is interested in handling alerts but not in either "active" or "dropped" + const isPending = !isActive && !isDropped; + if (isPending) { + return 'pending'; + } + + // Multiple Alertmanagers of the same URL may exist (e.g. with different credentials) + // Alertmanager response only contains URLs, so when the URL exists in both active and dropped, we are not able + // to distinguish which is which, resulting in an inconclusive status. + const isInconclusive = isActive && isDropped; + if (isInconclusive) { + return 'inconclusive'; + } + + // if we get here, it's neither "uninterested", nor "inconclusive" nor "pending" + if (isActive) { + return 'active'; + } else if (isDropped) { + return 'dropped'; + } + + return 'unknown'; +} + +// the vanilla Alertmanager and Mimir Alertmanager mount their API endpoints on different sub-paths +// Cortex also uses the same paths as Mimir +const MIMIR_ALERTMANAGER_PATH = '/alertmanager/api/v2/alerts'; +const VANILLA_ALERTMANAGER_PATH = '/api/v2/alerts'; + +// when using the Mimir Alertmanager, those paths are mounted under "/alertmanager" +function isAlertmanagerMatchByURL(dataSourceUrl: string, alertmanagerUrl: string) { + const normalizedUrl = normalizeDataSourceURL(dataSourceUrl); + + const prometheusAlertmanagerMatch = alertmanagerUrl === `${normalizedUrl}${VANILLA_ALERTMANAGER_PATH}`; + const mimirAlertmanagerMatch = alertmanagerUrl === `${normalizedUrl}${MIMIR_ALERTMANAGER_PATH}`; + + return prometheusAlertmanagerMatch || mimirAlertmanagerMatch; +} + +// Grafana prepends the http protocol if there isn't one, but it doesn't store that in the datasource settings +function normalizeDataSourceURL(url: string) { + const hasProtocol = new RegExp('^[^:]*://').test(url); + return hasProtocol ? url : `http://${url}`; } diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 1c6f623090f..88a216b58fb 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -602,19 +602,6 @@ export const grantUserPermissions = (permissions: AccessControlAction[]) => { .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(); diff --git a/public/app/features/alerting/unified/utils/config.ts b/public/app/features/alerting/unified/utils/config.ts index 9472a8b543e..d5f7817561d 100644 --- a/public/app/features/alerting/unified/utils/config.ts +++ b/public/app/features/alerting/unified/utils/config.ts @@ -1,12 +1,9 @@ import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; import { isValidPrometheusDuration, parsePrometheusDuration } from './time'; -export function getAllDataSources(): Array< - DataSourceInstanceSettings<DataSourceJsonData | AlertManagerDataSourceJsonData> -> { +export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> { return Object.values(config.datasources); } diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index de43e658649..e2f70cb8586 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -1,4 +1,4 @@ -import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; +import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceSettings } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { @@ -51,12 +51,22 @@ export function getRulesDataSource(rulesSourceName: string) { export function getAlertManagerDataSources() { return getAllDataSources() - .filter( - (ds): ds is DataSourceInstanceSettings<AlertManagerDataSourceJsonData> => ds.type === DataSourceType.Alertmanager - ) + .filter(isAlertmanagerDataSourceInstance) .sort((a, b) => a.name.localeCompare(b.name)); } +export function isAlertmanagerDataSourceInstance( + dataSource: DataSourceInstanceSettings +): dataSource is DataSourceInstanceSettings<AlertManagerDataSourceJsonData> { + return dataSource.type === DataSourceType.Alertmanager; +} + +export function isAlertmanagerDataSource( + dataSource: DataSourceSettings +): dataSource is DataSourceSettings<AlertManagerDataSourceJsonData> { + return dataSource.type === DataSourceType.Alertmanager; +} + export function getExternalDsAlertManagers() { return getAlertManagerDataSources().filter((ds) => ds.jsonData.handleGrafanaManagedAlerts); } @@ -205,10 +215,9 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings<Da } export function getAlertmanagerDataSourceByName(name: string) { - return getAllDataSources().find( - (source): source is DataSourceInstanceSettings<AlertManagerDataSourceJsonData> => - source.name === name && source.type === 'alertmanager' - ); + return getAllDataSources() + .filter(isAlertmanagerDataSourceInstance) + .find((source) => source.name === name); } export function getRulesSourceByName(name: string): RulesSource | undefined { diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 1b0017f3601..c6e4739353d 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 { UrlQueryMap, Labels, DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; +import { UrlQueryMap, Labels } from '@grafana/data'; import { GrafanaEdition } from '@grafana/data/src/types/config'; import { config } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; @@ -137,8 +137,8 @@ export function makeLabelBasedSilenceLink(alertManagerSourceName: string, labels return createUrl('/alerting/silence/new', silenceUrlParams); } -export function makeDataSourceLink<T extends DataSourceJsonData>(dataSource: DataSourceInstanceSettings<T>) { - return createUrl(`/datasources/edit/${dataSource.uid}`); +export function makeDataSourceLink(uid: string) { + return createUrl(`/datasources/edit/${uid}`); } export function makeFolderLink(folderUID: string): string {