diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 70df9a82829..92efd14eba7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1337,11 +1337,6 @@ "count": 2 } }, - "public/app/features/alerting/unified/api/onCallApi.test.ts": { - "no-restricted-syntax": { - "count": 2 - } - }, "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -1627,11 +1622,6 @@ "count": 1 } }, - "public/app/features/alerting/unified/mocks/server/configure.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, "public/app/features/alerting/unified/mocks/server/handlers/plugins.ts": { "no-restricted-syntax": { "count": 1 @@ -1662,16 +1652,6 @@ "count": 1 } }, - "public/app/features/alerting/unified/utils/config.test.ts": { - "no-restricted-syntax": { - "count": 6 - } - }, - "public/app/features/alerting/unified/utils/config.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, "public/app/features/alerting/unified/utils/datasource.ts": { "no-restricted-syntax": { "count": 2 diff --git a/public/app/features/alerting/unified/api/incidentsApi.ts b/public/app/features/alerting/unified/api/incidentsApi.ts index 737529c18d4..1e368535371 100644 --- a/public/app/features/alerting/unified/api/incidentsApi.ts +++ b/public/app/features/alerting/unified/api/incidentsApi.ts @@ -1,5 +1,3 @@ -import { getIrmIfPresentOrIncidentPluginId } from '../utils/config'; - import { alertingApi } from './alertingApi'; interface IncidentsPluginConfigDto { @@ -7,13 +5,13 @@ interface IncidentsPluginConfigDto { isIncidentCreated: boolean; } -const getProxyApiUrl = (path: string) => `/api/plugins/${getIrmIfPresentOrIncidentPluginId()}/resources${path}`; +const getProxyApiUrl = (path: string, pluginId: string) => `/api/plugins/${pluginId}/resources${path}`; export const incidentsApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ - getIncidentsPluginConfig: build.query({ - query: () => ({ - url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker'), + getIncidentsPluginConfig: build.query({ + query: ({ pluginId }) => ({ + url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker', pluginId), data: {}, method: 'POST', showErrorAlert: false, diff --git a/public/app/features/alerting/unified/api/onCallApi.test.ts b/public/app/features/alerting/unified/api/onCallApi.test.ts index a403f79b4a2..0e5307c5deb 100644 --- a/public/app/features/alerting/unified/api/onCallApi.test.ts +++ b/public/app/features/alerting/unified/api/onCallApi.test.ts @@ -1,26 +1,16 @@ -import { config } from '@grafana/runtime'; - -import { pluginMeta, pluginMetaToPluginConfig } from '../testSetup/plugins'; import { SupportedPlugin } from '../types/pluginBridges'; import { getProxyApiUrl } from './onCallApi'; describe('getProxyApiUrl', () => { - it('should return URL with IRM plugin ID when IRM plugin is present', () => { - config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) }; - - expect(getProxyApiUrl('/alert_receive_channels/')).toBe( + it('should return URL with IRM plugin ID when IRM plugin ID is passed', () => { + expect(getProxyApiUrl('/alert_receive_channels/', SupportedPlugin.Irm)).toBe( '/api/plugins/grafana-irm-app/resources/alert_receive_channels/' ); }); - it('should return URL with OnCall plugin ID when IRM plugin is not present', () => { - config.apps = { - [SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]), - [SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]), - }; - - expect(getProxyApiUrl('/alert_receive_channels/')).toBe( + it('should return URL with OnCall plugin ID when OnCall plugin ID is passed', () => { + expect(getProxyApiUrl('/alert_receive_channels/', SupportedPlugin.OnCall)).toBe( '/api/plugins/grafana-oncall-app/resources/alert_receive_channels/' ); }); diff --git a/public/app/features/alerting/unified/api/onCallApi.ts b/public/app/features/alerting/unified/api/onCallApi.ts index 0e35ae2f1b6..397b90a47d7 100644 --- a/public/app/features/alerting/unified/api/onCallApi.ts +++ b/public/app/features/alerting/unified/api/onCallApi.ts @@ -1,7 +1,6 @@ import { FetchError, isFetchError } from '@grafana/runtime'; import { GRAFANA_ONCALL_INTEGRATION_TYPE } from '../components/receivers/grafanaAppReceivers/onCall/onCall'; -import { getIrmIfPresentOrOnCallPluginId } from '../utils/config'; import { alertingApi } from './alertingApi'; @@ -38,15 +37,15 @@ export interface OnCallConfigChecks { is_integration_chatops_connected: boolean; } -export function getProxyApiUrl(path: string) { - return `/api/plugins/${getIrmIfPresentOrOnCallPluginId()}/resources${path}`; +export function getProxyApiUrl(path: string, pluginId: string) { + return `/api/plugins/${pluginId}/resources${path}`; } export const onCallApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ - grafanaOnCallIntegrations: build.query({ - query: () => ({ - url: getProxyApiUrl('/alert_receive_channels/'), + grafanaOnCallIntegrations: build.query({ + query: ({ pluginId }) => ({ + url: getProxyApiUrl('/alert_receive_channels/', pluginId), // legacy_grafana_alerting is necessary for OnCall. // We do NOT need to differentiate between these two on our side params: { @@ -64,31 +63,31 @@ export const onCallApi = alertingApi.injectEndpoints({ }, providesTags: ['OnCallIntegrations'], }), - validateIntegrationName: build.query({ - query: (name) => ({ - url: getProxyApiUrl('/alert_receive_channels/validate_name/'), + validateIntegrationName: build.query({ + query: ({ name, pluginId }) => ({ + url: getProxyApiUrl('/alert_receive_channels/validate_name/', pluginId), params: { verbal_name: name }, showErrorAlert: false, }), }), - createIntegration: build.mutation({ - query: (integration) => ({ - url: getProxyApiUrl('/alert_receive_channels/'), + createIntegration: build.mutation({ + query: ({ pluginId, ...integration }) => ({ + url: getProxyApiUrl('/alert_receive_channels/', pluginId), data: integration, method: 'POST', showErrorAlert: true, }), invalidatesTags: ['OnCallIntegrations'], }), - features: build.query({ - query: () => ({ - url: getProxyApiUrl('/features/'), + features: build.query({ + query: ({ pluginId }) => ({ + url: getProxyApiUrl('/features/', pluginId), showErrorAlert: false, }), }), - onCallConfigChecks: build.query({ - query: () => ({ - url: getProxyApiUrl('/organization/config-checks/'), + onCallConfigChecks: build.query({ + query: ({ pluginId }) => ({ + url: getProxyApiUrl('/organization/config-checks/', pluginId), showErrorAlert: false, }), }), diff --git a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx index bb2220b82a1..aa496345cd3 100644 --- a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx +++ b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx @@ -1,8 +1,8 @@ import { Trans, t } from '@grafana/i18n'; import { Button, LinkButton, Menu, Tooltip } from '@grafana/ui'; -import { usePluginBridge } from '../../hooks/usePluginBridge'; -import { getIrmIfPresentOrIncidentPluginId } from '../../utils/config'; +import { useIrmPlugin } from '../../hooks/usePluginBridge'; +import { SupportedPlugin } from '../../types/pluginBridges'; import { createBridgeURL } from '../PluginBridge'; interface Props { @@ -11,20 +11,18 @@ interface Props { url?: string; } -const pluginId = getIrmIfPresentOrIncidentPluginId(); - export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: Props) => { + const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident); + const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', { title, severity, url, }); - const { loading, installed, settings } = usePluginBridge(pluginId); - return ( <> - {loading === true && ( + {loading && ( @@ -51,17 +49,17 @@ export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: P }; export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => { + const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident); + const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', { title, severity, url, }); - const { loading, installed, settings } = usePluginBridge(pluginId); - return ( <> - {loading === true && ( + {loading && ( { - const { installed, loading } = usePluginBridge(getIrmIfPresentOrOnCallPluginId()); - const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery(undefined, { skip: skip || !installed }); + const { pluginId, installed, loading } = useIrmPlugin(SupportedPlugin.OnCall); + const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery({ pluginId }, { skip: skip || !installed }); return useMemo(() => { if (installed) { @@ -126,6 +126,10 @@ export const useGrafanaContactPoints = ({ }: GrafanaFetchOptions & Skippable = {}) => { const namespace = getAPINamespace(); const potentiallySkip = { skip }; + + // Get the IRM/OnCall plugin information + const irmOrOnCallPlugin = useIrmPlugin(SupportedPlugin.OnCall); + const onCallResponse = useOnCallIntegrations(potentiallySkip); const alertNotifiers = useGrafanaNotifiersQuery(undefined, potentiallySkip); const contactPointsListResponse = useK8sContactPoints({ namespace }, potentiallySkip); @@ -158,6 +162,7 @@ export const useGrafanaContactPoints = ({ status: contactPointsStatusResponse.data, notifiers: alertNotifiers.data, onCallIntegrations: onCallResponse?.data, + onCallPluginId: irmOrOnCallPlugin.pluginId, contactPoints: contactPointsListResponse.data || [], alertmanagerConfiguration: alertmanagerConfigResponse.data, }); @@ -172,6 +177,7 @@ export const useGrafanaContactPoints = ({ contactPointsListResponse, contactPointsStatusResponse, onCallResponse, + irmOrOnCallPlugin.pluginId, ]); }; 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 d2cc43901c1..2bbac9899e2 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -15,6 +15,7 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { OnCallIntegrationDTO } from '../../api/onCallApi'; +import { SupportedPlugin } from '../../types/pluginBridges'; import { extractReceivers } from '../../utils/receivers'; import { routeAdapter } from '../../utils/routeAdapter'; import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; @@ -113,7 +114,8 @@ export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { type EnhanceContactPointsArgs = { status?: ReceiversStateDTO[]; notifiers?: NotifierDTO[]; - onCallIntegrations?: OnCallIntegrationDTO[] | undefined | null; + onCallIntegrations?: OnCallIntegrationDTO[]; + onCallPluginId?: SupportedPlugin; contactPoints: Receiver[]; alertmanagerConfiguration?: AlertManagerCortexConfig; }; @@ -130,6 +132,7 @@ export function enhanceContactPointsWithMetadata({ status = [], notifiers = [], onCallIntegrations, + onCallPluginId, contactPoints, alertmanagerConfiguration, }: EnhanceContactPointsArgs): ContactPointWithMetadata[] { @@ -162,7 +165,7 @@ export function enhanceContactPointsWithMetadata({ [RECEIVER_META_KEY]: getNotifierMetadata(notifiers, receiver), // if OnCall plugin is installed, we'll add it to the receiver's plugin metadata [RECEIVER_PLUGIN_META_KEY]: isOnCallReceiver - ? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration)) + ? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration), onCallPluginId) : undefined, }; }), diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration.tsx b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration.tsx index 6f962f36805..cee53380565 100644 --- a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration.tsx +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration.tsx @@ -6,12 +6,12 @@ import { t } from '@grafana/i18n'; import { isFetchError } from '@grafana/runtime'; import { Badge } from '@grafana/ui'; import { NotifierDTO } from 'app/features/alerting/unified/types/alerting'; -import { getIrmIfPresentOrOnCallPluginId } from 'app/features/alerting/unified/utils/config'; import { useAppNotification } from '../../../../../../../core/copy/appNotification'; import { Receiver } from '../../../../../../../plugins/datasource/alertmanager/types'; import { ONCALL_INTEGRATION_V2_FEATURE, onCallApi } from '../../../../api/onCallApi'; -import { usePluginBridge } from '../../../../hooks/usePluginBridge'; +import { useIrmPlugin } from '../../../../hooks/usePluginBridge'; +import { SupportedPlugin } from '../../../../types/pluginBridges'; import { option } from '../../../../utils/notifier-types'; import { GRAFANA_ONCALL_INTEGRATION_TYPE, ReceiverTypes } from './onCall'; @@ -39,16 +39,17 @@ enum OnCallIntegrationStatus { function useOnCallPluginStatus() { const { + pluginId, installed: isOnCallEnabled, loading: isPluginBridgeLoading, error: pluginError, - } = usePluginBridge(getIrmIfPresentOrOnCallPluginId()); + } = useIrmPlugin(SupportedPlugin.OnCall); const { data: onCallFeatures = [], error: onCallFeaturesError, isLoading: isOnCallFeaturesLoading, - } = onCallApi.endpoints.features.useQuery(undefined, { skip: !isOnCallEnabled }); + } = onCallApi.endpoints.features.useQuery({ pluginId }, { skip: !isOnCallEnabled }); const integrationStatus = useMemo((): OnCallIntegrationStatus => { if (!isOnCallEnabled) { @@ -67,6 +68,7 @@ function useOnCallPluginStatus() { ); return { + pluginId, isOnCallEnabled, integrationStatus, isAlertingV2IntegrationEnabled, @@ -78,8 +80,14 @@ function useOnCallPluginStatus() { export function useOnCallIntegration() { const notifyApp = useAppNotification(); - const { isOnCallEnabled, integrationStatus, isAlertingV2IntegrationEnabled, isOnCallStatusLoading, onCallError } = - useOnCallPluginStatus(); + const { + pluginId, + isOnCallEnabled, + integrationStatus, + isAlertingV2IntegrationEnabled, + isOnCallStatusLoading, + onCallError, + } = useOnCallPluginStatus(); const { useCreateIntegrationMutation, useGrafanaOnCallIntegrationsQuery, useLazyValidateIntegrationNameQuery } = onCallApi; @@ -91,13 +99,13 @@ export function useOnCallIntegration() { data: grafanaOnCallIntegrations = [], isLoading: isLoadingOnCallIntegrations, isError: isIntegrationsQueryError, - } = useGrafanaOnCallIntegrationsQuery(undefined, { skip: !isAlertingV2IntegrationEnabled }); + } = useGrafanaOnCallIntegrationsQuery({ pluginId }, { skip: !isAlertingV2IntegrationEnabled }); const onCallFormValidators = useMemo(() => { return { integration_name: async (value: string) => { try { - await validateIntegrationNameQuery(value).unwrap(); + await validateIntegrationNameQuery({ name: value, pluginId }).unwrap(); return true; } catch (error) { if (isFetchError(error) && error.status === 409) { @@ -126,7 +134,7 @@ export function useOnCallIntegration() { : t('alerting.irm-integration.integration-required', 'Selection of existing IRM integration is required'); }, }; - }, [grafanaOnCallIntegrations, validateIntegrationNameQuery, isAlertingV2IntegrationEnabled, notifyApp]); + }, [grafanaOnCallIntegrations, validateIntegrationNameQuery, isAlertingV2IntegrationEnabled, notifyApp, pluginId]); const extendOnCallReceivers = useCallback( (receiver: Receiver): Receiver => { @@ -159,6 +167,7 @@ export function useOnCallIntegration() { const createNewOnCallIntegrationJobs = newOnCallIntegrations.map(async (c) => { const newIntegration = await createIntegrationMutation({ + pluginId, integration: GRAFANA_ONCALL_INTEGRATION_TYPE, verbal_name: c.settings[OnCallIntegrationSetting.IntegrationName], }).unwrap(); @@ -180,7 +189,7 @@ export function useOnCallIntegration() { }); }); }, - [isAlertingV2IntegrationEnabled, createIntegrationMutation] + [isAlertingV2IntegrationEnabled, createIntegrationMutation, pluginId] ); const extendOnCallNotifierFeatures = useCallback( diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts index 404e86e24c7..4c8477d2b5e 100644 --- a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts @@ -1,6 +1,9 @@ +import { useMemo } from 'react'; + import { GrafanaManagedReceiverConfig } from '../../../../../../plugins/datasource/alertmanager/types'; import { OnCallIntegrationDTO } from '../../../api/onCallApi'; -import { getIrmIfPresentOrOnCallPluginId, getIsIrmPluginPresent } from '../../../utils/config'; +import { useIrmPlugin } from '../../../hooks/usePluginBridge'; +import { SupportedPlugin } from '../../../types/pluginBridges'; import { createBridgeURL } from '../../PluginBridge'; import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from './types'; @@ -13,20 +16,34 @@ export interface ReceiverPluginMetadata { warning?: string; } -const onCallReceiverICon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[getIrmIfPresentOrOnCallPluginId()]; -const onCallReceiverTitle = 'Grafana OnCall'; - -export const onCallReceiverMeta: ReceiverPluginMetadata = { - title: onCallReceiverTitle, - icon: onCallReceiverICon, -}; - -export function getOnCallMetadata( +export function useOnCallMetadata( onCallIntegrations: OnCallIntegrationDTO[] | undefined | null, receiver: GrafanaManagedReceiverConfig, hasAlertManagerConfigData = true ): ReceiverPluginMetadata { - const pluginName = getIsIrmPluginPresent() ? 'IRM' : 'OnCall'; + const { pluginId } = useIrmPlugin(SupportedPlugin.OnCall); + + return useMemo( + () => getOnCallMetadata(onCallIntegrations, receiver, hasAlertManagerConfigData, pluginId), + [onCallIntegrations, receiver, hasAlertManagerConfigData, pluginId] + ); +} + +export function getOnCallMetadata( + onCallIntegrations: OnCallIntegrationDTO[] | undefined | null, + receiver: GrafanaManagedReceiverConfig, + hasAlertManagerConfigData = true, + onCallPluginId?: SupportedPlugin +): ReceiverPluginMetadata { + const pluginId = onCallPluginId || SupportedPlugin.OnCall; + const pluginName = pluginId === SupportedPlugin.Irm ? 'IRM' : 'OnCall'; + const onCallReceiverIcon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[pluginId]; + const onCallReceiverTitle = pluginId === SupportedPlugin.Irm ? 'Grafana IRM' : 'Grafana OnCall'; + + const onCallReceiverMeta: ReceiverPluginMetadata = { + title: onCallReceiverTitle, + icon: onCallReceiverIcon, + }; if (!hasAlertManagerConfigData) { return onCallReceiverMeta; @@ -57,7 +74,7 @@ export function getOnCallMetadata( ...onCallReceiverMeta, description: matchingOnCallIntegration?.display_name, externalUrl: matchingOnCallIntegration - ? createBridgeURL(getIrmIfPresentOrOnCallPluginId(), `/integrations/${matchingOnCallIntegration.value}`) + ? createBridgeURL(pluginId, `/integrations/${matchingOnCallIntegration.value}`) : undefined, warning: matchingOnCallIntegration ? undefined : `${pluginName} Integration no longer exists`, }; diff --git a/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx b/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx new file mode 100644 index 00000000000..f6e4a657797 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx @@ -0,0 +1,146 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { getPluginSettings } from 'app/features/plugins/pluginSettings'; + +import { pluginMeta } from '../testSetup/plugins'; +import { SupportedPlugin } from '../types/pluginBridges'; + +import { useIrmPlugin } from './usePluginBridge'; + +jest.mock('app/features/plugins/pluginSettings'); + +const mockedGetPluginSettings = jest.mocked(getPluginSettings); + +describe('useIrmPlugin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return IRM plugin ID when IRM plugin is installed', async () => { + mockedGetPluginSettings.mockImplementation((pluginId) => { + if (pluginId === SupportedPlugin.Irm) { + return Promise.resolve(pluginMeta[SupportedPlugin.Irm]); + } + if (pluginId === SupportedPlugin.OnCall) { + return Promise.resolve({ ...pluginMeta[SupportedPlugin.OnCall], enabled: false }); + } + return Promise.reject(new Error('Plugin not found')); + }); + + const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pluginId).toBe(SupportedPlugin.Irm); + expect(result.current.installed).toBe(true); + expect(result.current.settings).toBeDefined(); + }); + + it('should return OnCall plugin ID when IRM plugin is not installed', async () => { + mockedGetPluginSettings.mockImplementation((pluginId) => { + if (pluginId === SupportedPlugin.OnCall) { + return Promise.resolve(pluginMeta[SupportedPlugin.OnCall]); + } + return Promise.reject(new Error('Plugin not found')); + }); + + const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pluginId).toBe(SupportedPlugin.OnCall); + expect(result.current.installed).toBe(true); + expect(result.current.settings).toBeDefined(); + }); + + it('should return Incident plugin ID when IRM plugin is not installed', async () => { + mockedGetPluginSettings.mockImplementation((pluginId) => { + if (pluginId === SupportedPlugin.Incident) { + return Promise.resolve(pluginMeta[SupportedPlugin.Incident]); + } + return Promise.reject(new Error('Plugin not found')); + }); + + const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.Incident)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pluginId).toBe(SupportedPlugin.Incident); + expect(result.current.installed).toBe(true); + expect(result.current.settings).toBeDefined(); + }); + + it('should return loading state while fetching plugins', () => { + mockedGetPluginSettings.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(pluginMeta[SupportedPlugin.Irm]), 100)) + ); + + const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall)); + + expect(result.current.loading).toBe(true); + expect(result.current.installed).toBeUndefined(); + }); + + it('should return installed undefined when neither plugin is installed', async () => { + mockedGetPluginSettings.mockRejectedValue(new Error('Plugin not found')); + + const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pluginId).toBe(SupportedPlugin.OnCall); + expect(result.current.installed).toBeUndefined(); + }); + + it('should return IRM plugin ID when both IRM and OnCall are installed', async () => { + mockedGetPluginSettings.mockImplementation((pluginId) => { + if (pluginId === SupportedPlugin.Irm) { + return Promise.resolve(pluginMeta[SupportedPlugin.Irm]); + } + if (pluginId === SupportedPlugin.OnCall) { + return Promise.resolve(pluginMeta[SupportedPlugin.OnCall]); + } + return Promise.reject(new Error('Plugin not found')); + }); + + const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pluginId).toBe(SupportedPlugin.Irm); + expect(result.current.installed).toBe(true); + expect(result.current.settings).toEqual(pluginMeta[SupportedPlugin.Irm]); + }); + + it('should return IRM plugin ID when both IRM and Incident are installed', async () => { + mockedGetPluginSettings.mockImplementation((pluginId) => { + if (pluginId === SupportedPlugin.Irm) { + return Promise.resolve(pluginMeta[SupportedPlugin.Irm]); + } + if (pluginId === SupportedPlugin.Incident) { + return Promise.resolve(pluginMeta[SupportedPlugin.Incident]); + } + return Promise.reject(new Error('Plugin not found')); + }); + + const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.Incident)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pluginId).toBe(SupportedPlugin.Irm); + expect(result.current.installed).toBe(true); + expect(result.current.settings).toEqual(pluginMeta[SupportedPlugin.Irm]); + }); +}); diff --git a/public/app/features/alerting/unified/hooks/usePluginBridge.ts b/public/app/features/alerting/unified/hooks/usePluginBridge.ts index 1b6fa236791..f7f4bbbd485 100644 --- a/public/app/features/alerting/unified/hooks/usePluginBridge.ts +++ b/public/app/features/alerting/unified/hooks/usePluginBridge.ts @@ -4,6 +4,8 @@ import { PluginMeta } from '@grafana/data'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { PluginID } from '../components/PluginBridge'; +import { SupportedPlugin } from '../types/pluginBridges'; + interface PluginBridgeHookResponse { loading: boolean; installed?: boolean; @@ -14,17 +16,54 @@ interface PluginBridgeHookResponse { export function usePluginBridge(plugin: PluginID): PluginBridgeHookResponse { const { loading, error, value } = useAsync(() => getPluginSettings(plugin, { showErrorAlert: false })); - const installed = value && !error && !loading; - const enabled = value?.enabled; - const isLoading = loading && !value; - - if (isLoading) { + if (loading) { return { loading: true }; } - if (!installed || !enabled) { - return { loading: false, installed: false }; + if (error) { + return { loading, error }; } - return { loading, installed: true, settings: value }; + if (value) { + return { loading, installed: value.enabled ?? false, settings: value }; + } + + return { loading, installed: false }; +} + +type FallbackPlugin = SupportedPlugin.OnCall | SupportedPlugin.Incident; +type IrmWithFallback = SupportedPlugin.Irm | FallbackPlugin; + +export interface PluginBridgeResult { + pluginId: IrmWithFallback; + loading: boolean; + installed?: boolean; + error?: Error; + settings?: PluginMeta<{}>; +} +/** + * Hook that checks for IRM plugin first, falls back to specified plugin. + * IRM replaced both OnCall and Incident - this provides backward compatibility. + * + * @param fallback - The plugin to use if IRM is not installed (OnCall or Incident) + * @returns Bridge result with the active plugin data + * + * @example + * const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.OnCall); + */ +export function useIrmPlugin(fallback: FallbackPlugin): PluginBridgeResult { + const irmBridge = usePluginBridge(SupportedPlugin.Irm); + const fallbackBridge = usePluginBridge(fallback); + + const loading = irmBridge.loading || fallbackBridge.loading; + const pluginId = irmBridge.installed ? SupportedPlugin.Irm : fallback; + const activeBridge = irmBridge.installed ? irmBridge : fallbackBridge; + + return { + pluginId, + loading, + installed: activeBridge.installed, + error: activeBridge.error, + settings: activeBridge.settings, + }; } diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts index 3e3043d9331..1ab9aa08dab 100644 --- a/public/app/features/alerting/unified/mocks/server/configure.ts +++ b/public/app/features/alerting/unified/mocks/server/configure.ts @@ -1,6 +1,5 @@ import { type DefaultBodyType, HttpResponse, HttpResponseResolver, PathParams, http } from 'msw'; -import { config } from '@grafana/runtime'; import server from '@grafana/test-utils/server'; import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks'; import { @@ -10,10 +9,7 @@ import { } from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers'; import { getFolderHandler } from 'app/features/alerting/unified/mocks/server/handlers/folders'; import { listNamespacedTimeIntervalHandler } from 'app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s'; -import { - getDisabledPluginHandler, - getPluginMissingHandler, -} from 'app/features/alerting/unified/mocks/server/handlers/plugins'; +import { getDisabledPluginHandler } from 'app/features/alerting/unified/mocks/server/handlers/plugins'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse, @@ -212,12 +208,6 @@ export function setGrafanaPromRules(groups: GrafanaPromRuleGroupDTO[]) { server.use(http.get(`/api/prometheus/grafana/api/v1/rules`, paginatedHandlerFor(groups))); } -/** Make a given plugin ID respond with a 404, as if it isn't installed at all */ -export const removePlugin = (pluginId: string) => { - delete config.apps[pluginId]; - server.use(getPluginMissingHandler(pluginId)); -}; - /** Make a plugin respond with `enabled: false`, as if its installed but disabled */ export const disablePlugin = (pluginId: SupportedPlugin) => { clearPluginSettingsCache(pluginId); diff --git a/public/app/features/alerting/unified/utils/config.test.ts b/public/app/features/alerting/unified/utils/config.test.ts index 34a4734b55e..cab4615eb7a 100644 --- a/public/app/features/alerting/unified/utils/config.test.ts +++ b/public/app/features/alerting/unified/utils/config.test.ts @@ -1,14 +1,6 @@ import { config } from '@grafana/runtime'; -import { pluginMeta, pluginMetaToPluginConfig } from '../testSetup/plugins'; -import { SupportedPlugin } from '../types/pluginBridges'; - -import { - checkEvaluationIntervalGlobalLimit, - getIrmIfPresentOrIncidentPluginId, - getIrmIfPresentOrOnCallPluginId, - getIsIrmPluginPresent, -} from './config'; +import { checkEvaluationIntervalGlobalLimit } from './config'; describe('checkEvaluationIntervalGlobalLimit', () => { it('should NOT exceed limit if evaluate every is not valid duration', () => { @@ -59,48 +51,3 @@ describe('checkEvaluationIntervalGlobalLimit', () => { expect(exceedsLimit).toBe(false); }); }); - -describe('getIsIrmPluginPresent', () => { - it('should return true when IRM plugin is present in config.apps', () => { - config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) }; - expect(getIsIrmPluginPresent()).toBe(true); - }); - - it('should return false when IRM plugin is not present in config.apps', () => { - config.apps = { - [SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]), - [SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]), - }; - expect(getIsIrmPluginPresent()).toBe(false); - }); -}); - -describe('getIrmIfPresentOrIncidentPluginId', () => { - it('should return IRM plugin ID when IRM plugin is present', () => { - config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) }; - expect(getIrmIfPresentOrIncidentPluginId()).toBe(SupportedPlugin.Irm); - }); - - it('should return Incident plugin ID when IRM plugin is not present', () => { - config.apps = { - [SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]), - [SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]), - }; - expect(getIrmIfPresentOrIncidentPluginId()).toBe(SupportedPlugin.Incident); - }); -}); - -describe('getIrmIfPresentOrOnCallPluginId', () => { - it('should return IRM plugin ID when IRM plugin is present', () => { - config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) }; - expect(getIrmIfPresentOrOnCallPluginId()).toBe(SupportedPlugin.Irm); - }); - - it('should return OnCall plugin ID when IRM plugin is not present', () => { - config.apps = { - [SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]), - [SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]), - }; - expect(getIrmIfPresentOrOnCallPluginId()).toBe(SupportedPlugin.OnCall); - }); -}); diff --git a/public/app/features/alerting/unified/utils/config.ts b/public/app/features/alerting/unified/utils/config.ts index 6a85614622a..2183ebd5ce1 100644 --- a/public/app/features/alerting/unified/utils/config.ts +++ b/public/app/features/alerting/unified/utils/config.ts @@ -1,8 +1,6 @@ import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { SupportedPlugin } from '../types/pluginBridges'; - import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time'; export function getAllDataSources(): Array> { @@ -28,15 +26,3 @@ export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: str return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit }; } - -export function getIsIrmPluginPresent() { - return SupportedPlugin.Irm in config.apps; -} - -export function getIrmIfPresentOrIncidentPluginId() { - return getIsIrmPluginPresent() ? SupportedPlugin.Irm : SupportedPlugin.Incident; -} - -export function getIrmIfPresentOrOnCallPluginId() { - return getIsIrmPluginPresent() ? SupportedPlugin.Irm : SupportedPlugin.OnCall; -} diff --git a/public/app/features/gops/configuration-tracker/incidents/hooks.ts b/public/app/features/gops/configuration-tracker/incidents/hooks.ts index 748c21b4b11..d5ae0ba7539 100644 --- a/public/app/features/gops/configuration-tracker/incidents/hooks.ts +++ b/public/app/features/gops/configuration-tracker/incidents/hooks.ts @@ -1,6 +1,6 @@ import { incidentsApi } from 'app/features/alerting/unified/api/incidentsApi'; -import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge'; -import { getIrmIfPresentOrIncidentPluginId } from 'app/features/alerting/unified/utils/config'; +import { useIrmPlugin } from 'app/features/alerting/unified/hooks/usePluginBridge'; +import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; interface IncidentsPluginConfig { isInstalled: boolean; @@ -10,11 +10,13 @@ interface IncidentsPluginConfig { } export function useGetIncidentPluginConfig(): IncidentsPluginConfig { - const { installed: incidentPluginInstalled, loading: loadingPluginSettings } = usePluginBridge( - getIrmIfPresentOrIncidentPluginId() - ); + const { + pluginId, + installed: incidentPluginInstalled, + loading: loadingPluginSettings, + } = useIrmPlugin(SupportedPlugin.Incident); const { data: incidentsConfig, isLoading: loadingPluginConfig } = - incidentsApi.endpoints.getIncidentsPluginConfig.useQuery(); + incidentsApi.endpoints.getIncidentsPluginConfig.useQuery({ pluginId }); return { isInstalled: incidentPluginInstalled ?? false, diff --git a/public/app/features/gops/configuration-tracker/onCall/hooks.ts b/public/app/features/gops/configuration-tracker/onCall/hooks.ts index fe7fb50c056..5c92040cb3f 100644 --- a/public/app/features/gops/configuration-tracker/onCall/hooks.ts +++ b/public/app/features/gops/configuration-tracker/onCall/hooks.ts @@ -1,26 +1,34 @@ import { onCallApi } from 'app/features/alerting/unified/api/onCallApi'; -import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge'; -import { getIrmIfPresentOrOnCallPluginId } from 'app/features/alerting/unified/utils/config'; +import { useIrmPlugin } from 'app/features/alerting/unified/hooks/usePluginBridge'; +import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; export function useGetOnCallIntegrations() { - const { installed: onCallPluginInstalled } = usePluginBridge(getIrmIfPresentOrOnCallPluginId()); + const { pluginId, installed: onCallPluginInstalled } = useIrmPlugin(SupportedPlugin.OnCall); - const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(undefined, { - skip: !onCallPluginInstalled, - refetchOnFocus: true, - refetchOnReconnect: true, - refetchOnMountOrArgChange: true, - }); + const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery( + { pluginId }, + { + skip: !onCallPluginInstalled, + refetchOnFocus: true, + refetchOnReconnect: true, + refetchOnMountOrArgChange: true, + } + ); return onCallIntegrations ?? []; } function useGetOnCallConfigurationChecks() { - const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery(undefined, { - refetchOnFocus: true, - refetchOnReconnect: true, - refetchOnMountOrArgChange: true, - }); + const { pluginId } = useIrmPlugin(SupportedPlugin.OnCall); + + const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery( + { pluginId }, + { + refetchOnFocus: true, + refetchOnReconnect: true, + refetchOnMountOrArgChange: true, + } + ); return { isLoading,