diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index d4a1b73113b..06ff3cd0ae6 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -42,7 +42,12 @@ const ContactPointsTab = () => { const { selectedAlertmanager } = useAlertmanager(); const [queryParams] = useURLSearchParams(); - const { isLoading, error, contactPoints } = useContactPointsWithStatus(); + const { isLoading, error, contactPoints } = useContactPointsWithStatus({ + alertmanager: selectedAlertmanager!, + fetchPolicies: true, + fetchStatuses: true, + }); + const { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!); const [addContactPointSupported, addContactPointAllowed] = useAlertmanagerAbility( AlertmanagerAction.CreateContactPoint @@ -160,7 +165,9 @@ const ContactPointsPageContents = () => { const { selectedAlertmanager } = useAlertmanager(); const [activeTab, setActiveTab] = useTabQueryParam(); - const { contactPoints } = useContactPointsWithStatus(); + const { contactPoints } = useContactPointsWithStatus({ + alertmanager: selectedAlertmanager!, + }); const showingContactPoints = activeTab === ActiveTab.ContactPoints; const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates; diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx index 72f53409c25..e56c7fd44fb 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -2,66 +2,79 @@ import { renderHook, waitFor } from '@testing-library/react'; import { ReactNode } from 'react'; import { getWrapper } from 'test/test-utils'; +import { config } from '@grafana/runtime'; import { disablePlugin } from 'app/features/alerting/unified/mocks/server/configure'; import { setOnCallIntegrations } from 'app/features/alerting/unified/mocks/server/handlers/plugins/configure-plugins'; import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; +import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { AccessControlAction } from 'app/types'; import { setupMswServer } from '../../mockApi'; import { grantUserPermissions } from '../../mocks'; -import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { useContactPointsWithStatus } from './useContactPoints'; const wrapper = ({ children }: { children: ReactNode }) => { const ProviderWrapper = getWrapper({ renderWithRouter: true }); - return ( - - - {children} - - - ); + return {children}; }; setupMswServer(); +const getHookResponse = async (featureToggleEnabled: boolean) => { + config.featureToggles.alertingApiServer = featureToggleEnabled; + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: true, + fetchStatuses: true, + }), + { + wrapper, + } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Only return some properties, as we don't want to compare all + // RTK query properties in snapshots/comparison between k8s and non-k8s implementations + // (would include properties like requestId, fulfilled, etc.) + const { contactPoints, error, isLoading } = result.current; + + return { contactPoints, error, isLoading }; +}; + describe('useContactPoints', () => { - beforeAll(() => { + beforeEach(() => { grantUserPermissions([AccessControlAction.AlertingNotificationsRead]); + setOnCallIntegrations([ + { + display_name: 'grafana-integration', + value: 'ABC123', + integration_url: 'https://oncall-endpoint.example.com', + }, + ]); }); it('should return contact points with status', async () => { disablePlugin(SupportedPlugin.OnCall); + const snapshot = await getHookResponse(false); + expect(snapshot).toMatchSnapshot(); + }); - const { result } = renderHook(() => useContactPointsWithStatus(), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(result.current).toMatchSnapshot(); + it('returns matching responses with and without alertingApiServer', async () => { + const snapshotAmConfig = await getHookResponse(false); + const snapshotAlertingApiServer = await getHookResponse(true); + expect(snapshotAmConfig).toEqual(snapshotAlertingApiServer); }); describe('when having oncall plugin installed and no alert manager config data', () => { it('should return contact points with oncall metadata', async () => { - setOnCallIntegrations([ - { - display_name: 'grafana-integration', - value: 'ABC123', - integration_url: 'https://oncall-endpoint.example.com', - }, - ]); - - const { result } = renderHook(() => useContactPointsWithStatus(), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(result.current).toMatchSnapshot(); + const snapshot = await getHookResponse(false); + expect(snapshot).toMatchSnapshot(); }); }); }); diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx index c661ead47f0..1f0e3ed94c6 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx @@ -11,6 +11,7 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver, generatedReceiversApi, } from 'app/features/alerting/unified/openapi/receiversApi.gen'; +import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { cloudNotifierTypes } from 'app/features/alerting/unified/utils/cloud-alertmanager-notifier-types'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { getNamespace, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; @@ -18,7 +19,6 @@ import { getNamespace, shouldUseK8sApi } from 'app/features/alerting/unified/uti import { alertmanagerApi } from '../../api/alertmanagerApi'; import { onCallApi } from '../../api/onCallApi'; import { usePluginBridge } from '../../hooks/usePluginBridge'; -import { useAlertmanager } from '../../state/AlertmanagerContext'; import { SupportedPlugin } from '../../types/pluginBridges'; import { enhanceContactPointsWithMetadata } from './utils'; @@ -44,12 +44,17 @@ const { const { useGrafanaOnCallIntegrationsQuery } = onCallApi; const { useListNamespacedReceiverQuery } = generatedReceiversApi; +const defaultOptions = { + refetchOnFocus: true, + refetchOnReconnect: true, +}; + /** * Check if OnCall is installed, and fetch the list of integrations if so. * * Otherwise, returns no data */ -const useOnCallIntegrations = ({ skip }: { skip?: boolean } = {}) => { +const useOnCallIntegrations = ({ skip }: Skippable = {}) => { const { installed, loading } = usePluginBridge(SupportedPlugin.OnCall); const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery(undefined, { skip: skip || !installed }); @@ -84,24 +89,53 @@ const useK8sContactPoints = (...[hookParams, queryOptions]: Parameters { +/** + * Fetch contact points for Grafana Alertmanager, either from the k8s API, + * or the `/notifications/receivers` endpoint + */ +const useFetchGrafanaContactPoints = ({ skip }: Skippable = {}) => { const namespace = getNamespace(); const useK8sApi = shouldUseK8sApi(GRAFANA_RULES_SOURCE_NAME); - const grafanaResponse = useGetContactPointsListQuery(undefined, { skip: useK8sApi }); - const k8sResponse = useK8sContactPoints({ namespace }, { skip: !useK8sApi }); + + const grafanaResponse = useGetContactPointsListQuery(undefined, { skip: skip || useK8sApi }); + const k8sResponse = useK8sContactPoints({ namespace }, { skip: skip || !useK8sApi }); return useK8sApi ? k8sResponse : grafanaResponse; }; +type GrafanaFetchOptions = { + /** + * Should we fetch and include status information about each contact point? + */ + fetchStatuses?: boolean; + /** + * Should we fetch and include the number of notification policies that reference each contact point? + */ + fetchPolicies?: boolean; +}; + /** * Fetch contact points from separate endpoint (i.e. not the Alertmanager config) and combine with * OnCall integrations and any additional metadata from list of notifiers * (e.g. hydrate with additional names/descriptions) */ -export const useGetContactPoints = () => { - const onCallResponse = useOnCallIntegrations(); - const alertNotifiers = useGrafanaNotifiersQuery(); - const contactPointsListResponse = useGetGrafanaContactPoints(); +export const useGrafanaContactPoints = ({ + fetchStatuses, + fetchPolicies, + skip, +}: GrafanaFetchOptions & Skippable = {}) => { + const potentiallySkip = { skip }; + const onCallResponse = useOnCallIntegrations(potentiallySkip); + const alertNotifiers = useGrafanaNotifiersQuery(undefined, potentiallySkip); + const contactPointsListResponse = useFetchGrafanaContactPoints(potentiallySkip); + const contactPointsStatusResponse = useGetContactPointsStatusQuery(undefined, { + ...defaultOptions, + pollingInterval: RECEIVER_STATUS_POLLING_INTERVAL, + skip: skip || !fetchStatuses, + }); + const alertmanagerConfigResponse = useGetAlertmanagerConfigurationQuery(GRAFANA_RULES_SOURCE_NAME, { + skip: skip || !fetchPolicies, + }); return useMemo(() => { const isLoading = onCallResponse.isLoading || alertNotifiers.isLoading || contactPointsListResponse.isLoading; @@ -118,83 +152,55 @@ export const useGetContactPoints = () => { }; } - const enhanced = enhanceContactPointsWithMetadata( - [], - alertNotifiers.data, - onCallResponse?.data, - contactPointsListResponse.data, - undefined - ); + const enhanced = enhanceContactPointsWithMetadata({ + status: contactPointsStatusResponse.data, + notifiers: alertNotifiers.data, + onCallIntegrations: onCallResponse?.data, + contactPoints: contactPointsListResponse.data, + alertmanagerConfiguration: alertmanagerConfigResponse.data, + }); return { ...contactPointsListResponse, contactPoints: enhanced, }; }, [ - alertNotifiers.data, - alertNotifiers.isLoading, + alertNotifiers, + alertmanagerConfigResponse, contactPointsListResponse, - onCallResponse?.data, - onCallResponse.isLoading, + contactPointsStatusResponse, + onCallResponse, ]); }; -export function useContactPointsWithStatus() { - const { selectedAlertmanager, isGrafanaAlertmanager } = useAlertmanager(); - - const defaultOptions = { - refetchOnFocus: true, - refetchOnReconnect: true, - }; - - // fetch receiver status if we're dealing with a Grafana Managed Alertmanager - const fetchContactPointsStatus = useGetContactPointsStatusQuery(undefined, { - ...defaultOptions, - // re-fetch status every so often for up-to-date information - pollingInterval: RECEIVER_STATUS_POLLING_INTERVAL, - // skip fetching receiver statuses if not Grafana AM +export function useContactPointsWithStatus({ + alertmanager, + fetchStatuses, + fetchPolicies, +}: GrafanaFetchOptions & BaseAlertmanagerArgs) { + const isGrafanaAlertmanager = alertmanager === GRAFANA_RULES_SOURCE_NAME; + const grafanaResponse = useGrafanaContactPoints({ skip: !isGrafanaAlertmanager, + fetchStatuses, + fetchPolicies, }); - // fetch notifier metadata from the Grafana API if we're using a Grafana AM – this will be used to add additional - // metadata and canonical names to the receiver - const fetchReceiverMetadata = useGrafanaNotifiersQuery(undefined, { - skip: !isGrafanaAlertmanager, - }); - - // if the OnCall plugin is installed, fetch its list of integrations so we can match those to the Grafana Managed contact points - const { data: onCallMetadata, isLoading: onCallPluginIntegrationsLoading } = useOnCallIntegrations({ - skip: !isGrafanaAlertmanager, - }); - - // fetch the latest config from the Alertmanager - // we use this endpoint only when we need to get the number of policies - const fetchAlertmanagerConfiguration = useGetAlertmanagerConfigurationQuery(selectedAlertmanager!, { + const alertmanagerConfigResponse = useGetAlertmanagerConfigurationQuery(alertmanager, { ...defaultOptions, selectFromResult: (result) => ({ ...result, contactPoints: result.data - ? enhanceContactPointsWithMetadata( - fetchContactPointsStatus.data, - isGrafanaAlertmanager ? fetchReceiverMetadata.data : cloudNotifierTypes, - onCallMetadata, - result.data.alertmanager_config.receivers ?? [], - result.data - ) + ? enhanceContactPointsWithMetadata({ + notifiers: cloudNotifierTypes, + contactPoints: result.data.alertmanager_config.receivers ?? [], + alertmanagerConfiguration: result.data, + }) : [], }), + skip: isGrafanaAlertmanager, }); - // we will fail silently for fetching OnCall plugin status and integrations - const error = fetchAlertmanagerConfiguration.error || fetchContactPointsStatus.error; - const isLoading = - fetchAlertmanagerConfiguration.isLoading || fetchContactPointsStatus.isLoading || onCallPluginIntegrationsLoading; - - return { - error, - isLoading, - contactPoints: fetchAlertmanagerConfiguration.contactPoints, - }; + return isGrafanaAlertmanager ? grafanaResponse : alertmanagerConfigResponse; } export function useDeleteContactPoint(selectedAlertmanager: string) { diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index 1fe8c162a20..566e1f34763 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -102,6 +102,14 @@ export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { grafana_managed_receiver_configs: ReceiverConfigWithMetadata[]; } +type EnhanceContactPointsArgs = { + status?: ReceiversStateDTO[]; + notifiers?: NotifierDTO[]; + onCallIntegrations?: OnCallIntegrationDTO[] | undefined | null; + contactPoints: Receiver[]; + alertmanagerConfiguration?: AlertManagerCortexConfig; +}; + /** * This function adds the status information for each of the integrations (contact point types) in a contact point * 1. we iterate over all contact points @@ -110,13 +118,13 @@ export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { * alertmanagerConfiguration: optional as is passed when we need to get number of policies for each contact point * and we prefer using the data from the read-only endpoint. */ -export function enhanceContactPointsWithMetadata( - status: ReceiversStateDTO[] = [], - notifiers: NotifierDTO[] = [], - onCallIntegrations: OnCallIntegrationDTO[] | undefined | null, - contactPoints: Receiver[], - alertmanagerConfiguration?: AlertManagerCortexConfig -): ContactPointWithMetadata[] { +export function enhanceContactPointsWithMetadata({ + status = [], + notifiers = [], + onCallIntegrations, + contactPoints, + alertmanagerConfiguration, +}: EnhanceContactPointsArgs): ContactPointWithMetadata[] { // compute the entire inherited tree before finding what notification policies are using a particular contact point const fullyInheritedTree = computeInheritedTree(alertmanagerConfiguration?.alertmanager_config?.route ?? {}); const usedContactPoints = getUsedContactPoints(fullyInheritedTree); diff --git a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx index 9b6a67db99e..c5ef82d8ed8 100644 --- a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx @@ -13,6 +13,7 @@ import { ReadNamespacedTimeIntervalApiResponse, } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; import { deleteMuteTimingAction, updateAlertManagerConfigAction } from 'app/features/alerting/unified/state/actions'; +import { BaseAlertmanagerArgs } from 'app/features/alerting/unified/types/hooks'; import { renameMuteTimings } from 'app/features/alerting/unified/utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; @@ -27,15 +28,6 @@ const { useDeleteNamespacedTimeIntervalMutation, } = timeIntervalsApi; -type BaseAlertmanagerArgs = { - /** - * Name of alertmanager being used for mute timings management. - * - * Hooks will behave differently depending on whether this is `grafana` or an external alertmanager - */ - alertmanager: string; -}; - /** * Alertmanager mute time interval, with optional additional metadata * (returned in the case of K8S API implementation) diff --git a/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx index 9985715abc3..29d496d1eec 100644 --- a/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx @@ -1,12 +1,14 @@ import { SelectableValue } from '@grafana/data'; import { Select, SelectCommonProps, Text, Stack } from '@grafana/ui'; +import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from '../contact-points/constants'; import { useContactPointsWithStatus } from '../contact-points/useContactPoints'; import { ReceiverConfigWithMetadata } from '../contact-points/utils'; export const ContactPointSelector = (props: SelectCommonProps) => { - const { contactPoints, isLoading, error } = useContactPointsWithStatus(); + const { selectedAlertmanager } = useAlertmanager(); + const { contactPoints, isLoading, error } = useContactPointsWithStatus({ alertmanager: selectedAlertmanager! }); // TODO error handling if (error) { diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx index 25921e40e4a..cbfebc00157 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx @@ -8,7 +8,7 @@ import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource'; import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoint'; -import { useGetContactPoints } from '../../../contact-points/useContactPoints'; +import { useGrafanaContactPoints } from '../../../contact-points/useContactPoints'; import { ContactPointWithMetadata } from '../../../contact-points/utils'; import { ContactPointDetails } from './contactPoint/ContactPointDetails'; @@ -29,7 +29,7 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo error: errorInContactPointStatus, contactPoints, refetch: refetchReceivers, - } = useGetContactPoints(); + } = useGrafanaContactPoints(); const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState< ContactPointWithMetadata | undefined diff --git a/public/app/features/alerting/unified/types/hooks.ts b/public/app/features/alerting/unified/types/hooks.ts new file mode 100644 index 00000000000..8cd35251313 --- /dev/null +++ b/public/app/features/alerting/unified/types/hooks.ts @@ -0,0 +1,16 @@ +export type BaseAlertmanagerArgs = { + /** + * Name of alertmanager to use for config entity management + * + * Hooks will behave differently depending on whether this is `grafana` or an external alertmanager + */ + alertmanager: string; +}; + +export type Skippable = { + /** + * Should we skip requests altogether? + * Useful for cases where we want to conditionally call hook methods + */ + skip?: boolean; +};