diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index bab29a9acad..c8154839d1d 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -20,6 +20,7 @@ import teamsReducers from 'app/features/teams/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; +import { alertingApi } from '../../features/alerting/unified/api/alertingApi'; import { CleanUp, cleanUpAction } from '../actions/cleanUp'; const rootReducers = { @@ -42,6 +43,7 @@ const rootReducers = { ...panelsReducers, ...templatingReducers, plugins: pluginsReducer, + [alertingApi.reducerPath]: alertingApi.reducer, }; const addedReducers = {}; diff --git a/public/app/features/alerting/unified/AmRoutes.test.tsx b/public/app/features/alerting/unified/AmRoutes.test.tsx index 96c08e6f39c..01462fdc863 100644 --- a/public/app/features/alerting/unified/AmRoutes.test.tsx +++ b/public/app/features/alerting/unified/AmRoutes.test.tsx @@ -20,6 +20,7 @@ import { AccessControlAction } from 'app/types'; import AmRoutes from './AmRoutes'; import { fetchAlertManagerConfig, fetchStatus, updateAlertManagerConfig } from './api/alertmanager'; +import { discoverAlertmanagerFeatures } from './api/buildInfo'; import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks'; import { defaultGroupBy } from './utils/amroutes'; import { getAllDataSources } from './utils/config'; @@ -29,6 +30,7 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; jest.mock('./api/alertmanager'); jest.mock('./utils/config'); jest.mock('app/core/services/context_srv'); +jest.mock('./api/buildInfo'); const mocks = { getAllDataSourcesMock: jest.mocked(getAllDataSources), @@ -37,6 +39,7 @@ const mocks = { fetchAlertManagerConfig: jest.mocked(fetchAlertManagerConfig), updateAlertManagerConfig: jest.mocked(updateAlertManagerConfig), fetchStatus: jest.mocked(fetchStatus), + discoverAlertmanagerFeatures: jest.mocked(discoverAlertmanagerFeatures), }, contextSrv: jest.mocked(contextSrv), }; @@ -83,6 +86,8 @@ const ui = { editButton: byRole('button', { name: 'Edit' }), saveButton: byRole('button', { name: 'Save' }), + setDefaultReceiverCTA: byRole('button', { name: 'Set a default contact point' }), + editRouteButton: byLabelText('Edit route'), deleteRouteButton: byLabelText('Delete route'), newPolicyButton: byRole('button', { name: /New policy/ }), @@ -192,6 +197,7 @@ describe('AmRoutes', () => { mocks.contextSrv.hasAccess.mockImplementation(() => true); mocks.contextSrv.hasPermission.mockImplementation(() => true); mocks.contextSrv.evaluatePermission.mockImplementation(() => []); + mocks.api.discoverAlertmanagerFeatures.mockResolvedValue({ lazyConfigInit: false }); setDataSourceSrv(new MockDataSourceSrv(dataSources)); }); @@ -499,7 +505,7 @@ describe('AmRoutes', () => { mocks.api.updateAlertManagerConfig.mockResolvedValue(Promise.resolve()); await renderAmRoutes(GRAFANA_RULES_SOURCE_NAME); - expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled(); + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled()); const deleteButtons = await ui.deleteRouteButton.findAll(); expect(deleteButtons).toHaveLength(1); @@ -697,6 +703,21 @@ describe('AmRoutes', () => { }, }); }); + + it('Shows an empty config when config returns an error and the AM supports lazy config initialization', async () => { + mocks.api.discoverAlertmanagerFeatures.mockResolvedValue({ lazyConfigInit: true }); + + mocks.api.fetchAlertManagerConfig.mockRejectedValue({ + message: 'alertmanager storage object not found', + }); + + await renderAmRoutes(); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1)); + + expect(ui.rootReceiver.query()).toBeInTheDocument(); + expect(ui.setDefaultReceiverCTA.query()).toBeInTheDocument(); + }); }); const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise => { diff --git a/public/app/features/alerting/unified/Receivers.test.tsx b/public/app/features/alerting/unified/Receivers.test.tsx index a58669d56f6..9bc2b11fb20 100644 --- a/public/app/features/alerting/unified/Receivers.test.tsx +++ b/public/app/features/alerting/unified/Receivers.test.tsx @@ -16,6 +16,7 @@ import { AccessControlAction } from 'app/types'; import Receivers from './Receivers'; import { updateAlertManagerConfig, fetchAlertManagerConfig, fetchStatus, testReceivers } from './api/alertmanager'; +import { discoverAlertmanagerFeatures } from './api/buildInfo'; import { fetchNotifiers } from './api/grafana'; import { mockDataSource, @@ -33,6 +34,7 @@ jest.mock('./api/alertmanager'); jest.mock('./api/grafana'); jest.mock('./utils/config'); jest.mock('app/core/services/context_srv'); +jest.mock('./api/buildInfo'); const mocks = { getAllDataSources: jest.mocked(getAllDataSources), @@ -43,6 +45,7 @@ const mocks = { updateConfig: jest.mocked(updateAlertManagerConfig), fetchNotifiers: jest.mocked(fetchNotifiers), testReceivers: jest.mocked(testReceivers), + discoverAlertmanagerFeatures: jest.mocked(discoverAlertmanagerFeatures), }, contextSrv: jest.mocked(contextSrv), }; @@ -129,6 +132,7 @@ describe('Receivers', () => { jest.resetAllMocks(); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock); + mocks.api.discoverAlertmanagerFeatures.mockResolvedValue({ lazyConfigInit: false }); setDataSourceSrv(new MockDataSourceSrv(dataSources)); mocks.contextSrv.isEditor = true; store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); @@ -470,4 +474,18 @@ describe('Receivers', () => { expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1); expect(mocks.api.fetchStatus).toHaveBeenLastCalledWith('CloudManager'); }); + + it('Shows an empty config when config returns an error and the AM supports lazy config initialization', async () => { + mocks.api.discoverAlertmanagerFeatures.mockResolvedValue({ lazyConfigInit: true }); + mocks.api.fetchConfig.mockRejectedValue({ message: 'alertmanager storage object not found' }); + + await renderReceivers('CloudManager'); + + const templatesTable = await ui.templatesTable.find(); + const receiversTable = await ui.receiversTable.find(); + + expect(templatesTable).toBeInTheDocument(); + expect(receiversTable).toBeInTheDocument(); + expect(ui.newContactPointButton.get()).toBeInTheDocument(); + }); }); diff --git a/public/app/features/alerting/unified/Silences.tsx b/public/app/features/alerting/unified/Silences.tsx index c0c1eda8135..76587e0cd40 100644 --- a/public/app/features/alerting/unified/Silences.tsx +++ b/public/app/features/alerting/unified/Silences.tsx @@ -5,6 +5,7 @@ import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react- import { Alert, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui'; import { Silence } from 'app/plugins/datasource/alertmanager/types'; +import { featureDiscoveryApi } from './api/featureDiscoveryApi'; import { AlertManagerPicker } from './components/AlertManagerPicker'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { NoAlertManagerWarning } from './components/NoAlertManagerWarning'; @@ -31,6 +32,11 @@ const Silences: FC = () => { const location = useLocation(); const isRoot = location.pathname.endsWith('/alerting/silences'); + const { currentData: amFeatures } = featureDiscoveryApi.useDiscoverAmFeaturesQuery( + { amSourceName: alertManagerSourceName ?? '' }, + { skip: !alertManagerSourceName } + ); + useEffect(() => { function fetchAll() { if (alertManagerSourceName) { @@ -50,6 +56,9 @@ const Silences: FC = () => { const getSilenceById = useCallback((id: string) => result && result.find((silence) => silence.id === id), [result]); + const mimirLazyInitError = + error?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit; + if (!alertManagerSourceName) { return isRoot ? ( @@ -68,12 +77,19 @@ const Silences: FC = () => { onChange={setAlertManagerSourceName} dataSources={alertManagers} /> - {error && !loading && ( + + {mimirLazyInitError && ( + + Create a new contact point to create a configuration using the default values or contact your administrator to + set up the Alertmanager. + + )} + {error && !loading && !mimirLazyInitError && ( {error.message || 'Unknown error.'} )} - {alertsRequest?.error && !alertsRequest?.loading && ( + {alertsRequest?.error && !alertsRequest?.loading && !mimirLazyInitError && ( {alertsRequest.error?.message || 'Unknown error.'} diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts new file mode 100644 index 00000000000..473900da265 --- /dev/null +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -0,0 +1,20 @@ +import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; +import { lastValueFrom } from 'rxjs'; + +import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; + +const backendSrvBaseQuery = (): BaseQueryFn => async (requestOptions) => { + try { + const { data, ...meta } = await lastValueFrom(getBackendSrv().fetch(requestOptions)); + + return { data, meta }; + } catch (error) { + return { error }; + } +}; + +export const alertingApi = createApi({ + reducerPath: 'alertingApi', + baseQuery: backendSrvBaseQuery(), + endpoints: () => ({}), +}); diff --git a/public/app/features/alerting/unified/api/buildInfo.ts b/public/app/features/alerting/unified/api/buildInfo.ts index cc623411e5e..5cf40832d0f 100644 --- a/public/app/features/alerting/unified/api/buildInfo.ts +++ b/public/app/features/alerting/unified/api/buildInfo.ts @@ -1,14 +1,48 @@ import { lastValueFrom } from 'rxjs'; import { getBackendSrv, isFetchError } from '@grafana/runtime'; -import { PromApplication, PromApiFeatures, PromBuildInfoResponse } from 'app/types/unified-alerting-dto'; +import { + AlertmanagerApiFeatures, + PromApiFeatures, + PromApplication, + PromBuildInfoResponse, +} from 'app/types/unified-alerting-dto'; import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; -import { getDataSourceByName } from '../utils/datasource'; +import { getDataSourceByName, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { fetchRules } from './prometheus'; import { fetchTestRulerRulesGroup } from './ruler'; +/** + * Attempt to fetch buildinfo from our component + */ +export async function discoverFeatures(dataSourceName: string): Promise { + if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { + return { + features: { + rulerApiEnabled: true, + }, + }; + } + + const dsConfig = getDataSourceByName(dataSourceName); + if (!dsConfig) { + throw new Error(`Cannot find data source configuration for ${dataSourceName}`); + } + + const { url, name, type } = dsConfig; + if (!url) { + throw new Error(`The data source url cannot be empty.`); + } + + if (type !== 'prometheus' && type !== 'loki') { + throw new Error(`The build info request is not available for ${type}. Only 'prometheus' and 'loki' are supported`); + } + + return discoverDataSourceFeatures({ name, url, type }); +} + /** * This function will attempt to detect what type of system we are talking to; this could be * Prometheus (vanilla) | Cortex | Mimir @@ -27,7 +61,7 @@ export async function discoverDataSourceFeatures(dsSettings: { // The current implementation of Loki's build info endpoint is useless // because it doesn't provide information about Loki's available features (e.g. Ruler API) // It's better to skip fetching it for Loki and go the Cortex path (manual discovery) - const buildInfoResponse = type === 'prometheus' ? await fetchPromBuildInfo(url) : undefined; + const buildInfoResponse = type === 'loki' ? undefined : await fetchPromBuildInfo(url); // check if the component returns buildinfo const hasBuildInfo = buildInfoResponse !== undefined; @@ -51,7 +85,7 @@ export async function discoverDataSourceFeatures(dsSettings: { }; } - // if no features are reported but buildinfo was return we're talking to Prometheus + // if no features are reported but buildinfo was returned we're talking to Prometheus const { features } = buildInfoResponse.data; if (!features) { return { @@ -71,27 +105,46 @@ export async function discoverDataSourceFeatures(dsSettings: { }; } -/** - * Attempt to fetch buildinfo from our component - */ -export async function discoverFeatures(dataSourceName: string): Promise { - const dsConfig = getDataSourceByName(dataSourceName); - if (!dsConfig) { - throw new Error(`Cannot find data source configuration for ${dataSourceName}`); +export async function discoverAlertmanagerFeatures(amSourceName: string): Promise { + if (amSourceName === GRAFANA_RULES_SOURCE_NAME) { + return { lazyConfigInit: false }; } - const { url, name, type } = dsConfig; + + const dsConfig = getDataSourceConfig(amSourceName); + + const { url, type } = dsConfig; if (!url) { - throw new Error(`The data souce url cannot be empty.`); + throw new Error(`The data source url cannot be empty.`); } - if (type !== 'prometheus' && type !== 'loki') { - throw new Error(`The build info request is not available for ${type}. Only 'prometheus' and 'loki' are supported`); + if (type !== 'alertmanager') { + throw new Error( + `Alertmanager feature discovery is not available for ${type}. Only 'alertmanager' type is supported` + ); } - return discoverDataSourceFeatures({ name, url, type }); + return await discoverAlertmanagerFeaturesByUrl(url); } -async function fetchPromBuildInfo(url: string): Promise { +export async function discoverAlertmanagerFeaturesByUrl(url: string): Promise { + try { + const buildInfo = await fetchPromBuildInfo(url); + return { lazyConfigInit: buildInfo?.data?.application === 'Grafana Mimir' }; + } catch (e) { + // If we cannot access the build info then we assume the lazy config is not available + return { lazyConfigInit: false }; + } +} + +function getDataSourceConfig(amSourceName: string) { + const dsConfig = getDataSourceByName(amSourceName); + if (!dsConfig) { + throw new Error(`Cannot find data source configuration for ${amSourceName}`); + } + return dsConfig; +} + +export async function fetchPromBuildInfo(url: string): Promise { const response = await lastValueFrom( getBackendSrv().fetch({ url: `${url}/api/v1/status/buildinfo`, @@ -136,7 +189,6 @@ async function hasRulerSupport(dataSourceName: string) { throw e; } } - // there errors indicate that the ruler API might be disabled or not supported for Cortex function errorIndicatesMissingRulerSupport(error: any) { return ( diff --git a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts new file mode 100644 index 00000000000..2f7f00bd31f --- /dev/null +++ b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts @@ -0,0 +1,19 @@ +import { AlertmanagerApiFeatures } from '../../../../types/unified-alerting-dto'; + +import { alertingApi } from './alertingApi'; +import { discoverAlertmanagerFeatures } from './buildInfo'; + +export const featureDiscoveryApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + discoverAmFeatures: build.query({ + queryFn: async ({ amSourceName }) => { + try { + const amFeatures = await discoverAlertmanagerFeatures(amSourceName); + return { data: amFeatures }; + } catch (error) { + return { error: error }; + } + }, + }), + }), +}); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 946118b3096..3043cfede1e 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -13,7 +13,6 @@ import { SilenceCreatePayload, TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; -import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError'; import { FolderDTO, NotifierDTO, StoreState, ThunkResult } from 'app/types'; import { CombinedRuleGroup, @@ -50,6 +49,7 @@ import { } from '../api/alertmanager'; import { fetchAnnotations } from '../api/annotations'; import { discoverFeatures } from '../api/buildInfo'; +import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; import { fetchNotifiers } from '../api/grafana'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { @@ -69,7 +69,7 @@ import { isVanillaPrometheusAlertManagerDataSource, } from '../utils/datasource'; import { makeAMLink, retryWhile } from '../utils/misc'; -import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; +import { AsyncRequestMapSlice, messageFromError, withAppEvents, withSerializedError } from '../utils/redux'; import * as ruleId from '../utils/rule-id'; import { getRulerClient } from '../utils/rulerClient'; import { isRulerNotSupportedResponse } from '../utils/rules'; @@ -108,7 +108,7 @@ export const fetchPromRulesAction = createAsyncThunk( export const fetchAlertManagerConfigAction = createAsyncThunk( 'unifiedalerting/fetchAmConfig', - (alertManagerSourceName: string): Promise => + (alertManagerSourceName: string, thunkAPI): Promise => withSerializedError( (async () => { // for vanilla prometheus, there is no config endpoint. Only fetch config from status @@ -119,27 +119,49 @@ export const fetchAlertManagerConfigAction = createAsyncThunk( })); } + const { data: amFeatures } = await thunkAPI.dispatch( + featureDiscoveryApi.endpoints.discoverAmFeatures.initiate({ + amSourceName: alertManagerSourceName, + }) + ); + + const lazyConfigInitSupported = amFeatures?.lazyConfigInit ?? false; + return retryWhile( () => fetchAlertManagerConfig(alertManagerSourceName), // if config has been recently deleted, it takes a while for cortex start returning the default one. // retry for a short while instead of failing - (e) => !!messageFromError(e)?.includes('alertmanager storage object not found'), + (e) => !!messageFromError(e)?.includes('alertmanager storage object not found') && !lazyConfigInitSupported, FETCH_CONFIG_RETRY_TIMEOUT - ).then((result) => { - // if user config is empty for cortex alertmanager, try to get config from status endpoint - if ( - isEmpty(result.alertmanager_config) && - isEmpty(result.template_files) && - alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME - ) { - return fetchStatus(alertManagerSourceName).then((status) => ({ - alertmanager_config: status.config, - template_files: {}, - template_file_provenances: result.template_file_provenances, - })); - } - return result; - }); + ) + .then((result) => { + // if user config is empty for cortex alertmanager, try to get config from status endpoint + if ( + isEmpty(result.alertmanager_config) && + isEmpty(result.template_files) && + alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME + ) { + return fetchStatus(alertManagerSourceName).then((status) => ({ + alertmanager_config: status.config, + template_files: {}, + template_file_provenances: result.template_file_provenances, + })); + } + return result; + }) + .catch((e) => { + // When mimir doesn't have fallback AM url configured the default response will be as above + // However it's fine, and it's possible to create AM configuration + if (lazyConfigInitSupported && messageFromError(e)?.includes('alertmanager storage object not found')) { + return Promise.resolve({ + alertmanager_config: {}, + template_files: {}, + template_file_provenances: {}, + }); + } + + throw e; + }); })() ) ); @@ -452,7 +474,8 @@ export const updateAlertManagerConfigAction = createAsyncThunk { - const latestConfig = await fetchAlertManagerConfig(alertManagerSourceName); + const latestConfig = await thunkAPI.dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)).unwrap(); + if ( !(isEmpty(latestConfig.alertmanager_config) && isEmpty(latestConfig.template_files)) && JSON.stringify(latestConfig) !== JSON.stringify(oldConfig) diff --git a/public/app/plugins/datasource/alertmanager/DataSource.ts b/public/app/plugins/datasource/alertmanager/DataSource.ts index 5616ac4ab51..315e85864c6 100644 --- a/public/app/plugins/datasource/alertmanager/DataSource.ts +++ b/public/app/plugins/datasource/alertmanager/DataSource.ts @@ -1,7 +1,11 @@ import { lastValueFrom, Observable, of } from 'rxjs'; import { DataQuery, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; -import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; +import { BackendSrvRequest, getBackendSrv, isFetchError } from '@grafana/runtime'; + +import { discoverAlertmanagerFeaturesByUrl } from '../../../features/alerting/unified/api/buildInfo'; +import { messageFromError } from '../../../features/alerting/unified/utils/redux'; +import { AlertmanagerApiFeatures } from '../../../types/unified-alerting-dto'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types'; @@ -43,6 +47,11 @@ export class AlertManagerDatasource extends DataSourceApi) { - const store = reduxConfigureStore]>>({ + const store = reduxConfigureStore({ reducer: createRootReducer(), middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }), + getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat( + alertingApi.middleware + ), devTools: process.env.NODE_ENV !== 'production', preloadedState: { navIndex: buildInitialState(), diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 8a447a9c1bb..6f0604b9cfb 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -78,6 +78,19 @@ export interface PromApiFeatures { }; } +export interface AlertmanagerApiFeatures { + /** + * Some Alertmanager implementations (Mimir) are multi-tenant systems. + * + * To save on compute costs, tenants are not active until they have a configuration set. + * If there is no fallback_config_file set, Alertmanager endpoints will respond with HTTP 404 + * + * Despite that, it is possible to create a configuration for such datasource + * by posting a new config to the `/api/v1/alerts` endpoint + */ + lazyConfigInit: boolean; +} + interface PromRuleDTOBase { health: string; name: string;