Compare commits

...

1 Commits

Author SHA1 Message Date
Konrad Lalik 8595844334 Alerting: Migrate IRM/OnCall/Incident plugin detection to async hooks
Replace synchronous config.apps-based plugin detection with async
hook-based approach using the new useIrmPlugin hook.

Previously, functions like getIrmIfPresentOrOnCallPluginId() read from
config.apps synchronously, which wasn't reliable as it depended on
config being pre-populated. This caused issues where API calls were
made without knowing which plugin was actually installed.

Changes:
- Add useIrmPlugin hook that checks for IRM first, falls back to
  OnCall/Incident. Returns pluginId so callers know which plugin to use
- Update all RTK Query endpoints to accept pluginId as parameter
- Remove synchronous helpers: getIsIrmPluginPresent(),
  getIrmIfPresentOrIncidentPluginId(), getIrmIfPresentOrOnCallPluginId()
- Update all consumers to use useIrmPlugin hook
- Add comprehensive tests for useIrmPlugin hook
- Clean up ESLint suppressions for removed config.apps usage
2026-01-14 16:59:11 +01:00
16 changed files with 321 additions and 203 deletions
-20
View File
@@ -1337,11 +1337,6 @@
"count": 2 "count": 2
} }
}, },
"public/app/features/alerting/unified/api/onCallApi.test.ts": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": { "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 1 "count": 1
@@ -1627,11 +1622,6 @@
"count": 1 "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": { "public/app/features/alerting/unified/mocks/server/handlers/plugins.ts": {
"no-restricted-syntax": { "no-restricted-syntax": {
"count": 1 "count": 1
@@ -1662,16 +1652,6 @@
"count": 1 "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": { "public/app/features/alerting/unified/utils/datasource.ts": {
"no-restricted-syntax": { "no-restricted-syntax": {
"count": 2 "count": 2
@@ -1,5 +1,3 @@
import { getIrmIfPresentOrIncidentPluginId } from '../utils/config';
import { alertingApi } from './alertingApi'; import { alertingApi } from './alertingApi';
interface IncidentsPluginConfigDto { interface IncidentsPluginConfigDto {
@@ -7,13 +5,13 @@ interface IncidentsPluginConfigDto {
isIncidentCreated: boolean; 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({ export const incidentsApi = alertingApi.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
getIncidentsPluginConfig: build.query<IncidentsPluginConfigDto, void>({ getIncidentsPluginConfig: build.query<IncidentsPluginConfigDto, { pluginId: string }>({
query: () => ({ query: ({ pluginId }) => ({
url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker'), url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker', pluginId),
data: {}, data: {},
method: 'POST', method: 'POST',
showErrorAlert: false, showErrorAlert: false,
@@ -1,26 +1,16 @@
import { config } from '@grafana/runtime';
import { pluginMeta, pluginMetaToPluginConfig } from '../testSetup/plugins';
import { SupportedPlugin } from '../types/pluginBridges'; import { SupportedPlugin } from '../types/pluginBridges';
import { getProxyApiUrl } from './onCallApi'; import { getProxyApiUrl } from './onCallApi';
describe('getProxyApiUrl', () => { describe('getProxyApiUrl', () => {
it('should return URL with IRM plugin ID when IRM plugin is present', () => { it('should return URL with IRM plugin ID when IRM plugin ID is passed', () => {
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) }; expect(getProxyApiUrl('/alert_receive_channels/', SupportedPlugin.Irm)).toBe(
expect(getProxyApiUrl('/alert_receive_channels/')).toBe(
'/api/plugins/grafana-irm-app/resources/alert_receive_channels/' '/api/plugins/grafana-irm-app/resources/alert_receive_channels/'
); );
}); });
it('should return URL with OnCall plugin ID when IRM plugin is not present', () => { it('should return URL with OnCall plugin ID when OnCall plugin ID is passed', () => {
config.apps = { expect(getProxyApiUrl('/alert_receive_channels/', SupportedPlugin.OnCall)).toBe(
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
};
expect(getProxyApiUrl('/alert_receive_channels/')).toBe(
'/api/plugins/grafana-oncall-app/resources/alert_receive_channels/' '/api/plugins/grafana-oncall-app/resources/alert_receive_channels/'
); );
}); });
@@ -1,7 +1,6 @@
import { FetchError, isFetchError } from '@grafana/runtime'; import { FetchError, isFetchError } from '@grafana/runtime';
import { GRAFANA_ONCALL_INTEGRATION_TYPE } from '../components/receivers/grafanaAppReceivers/onCall/onCall'; import { GRAFANA_ONCALL_INTEGRATION_TYPE } from '../components/receivers/grafanaAppReceivers/onCall/onCall';
import { getIrmIfPresentOrOnCallPluginId } from '../utils/config';
import { alertingApi } from './alertingApi'; import { alertingApi } from './alertingApi';
@@ -38,15 +37,15 @@ export interface OnCallConfigChecks {
is_integration_chatops_connected: boolean; is_integration_chatops_connected: boolean;
} }
export function getProxyApiUrl(path: string) { export function getProxyApiUrl(path: string, pluginId: string) {
return `/api/plugins/${getIrmIfPresentOrOnCallPluginId()}/resources${path}`; return `/api/plugins/${pluginId}/resources${path}`;
} }
export const onCallApi = alertingApi.injectEndpoints({ export const onCallApi = alertingApi.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], void>({ grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], { pluginId: string }>({
query: () => ({ query: ({ pluginId }) => ({
url: getProxyApiUrl('/alert_receive_channels/'), url: getProxyApiUrl('/alert_receive_channels/', pluginId),
// legacy_grafana_alerting is necessary for OnCall. // legacy_grafana_alerting is necessary for OnCall.
// We do NOT need to differentiate between these two on our side // We do NOT need to differentiate between these two on our side
params: { params: {
@@ -64,31 +63,31 @@ export const onCallApi = alertingApi.injectEndpoints({
}, },
providesTags: ['OnCallIntegrations'], providesTags: ['OnCallIntegrations'],
}), }),
validateIntegrationName: build.query<boolean, string>({ validateIntegrationName: build.query<boolean, { name: string; pluginId: string }>({
query: (name) => ({ query: ({ name, pluginId }) => ({
url: getProxyApiUrl('/alert_receive_channels/validate_name/'), url: getProxyApiUrl('/alert_receive_channels/validate_name/', pluginId),
params: { verbal_name: name }, params: { verbal_name: name },
showErrorAlert: false, showErrorAlert: false,
}), }),
}), }),
createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO>({ createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO & { pluginId: string }>({
query: (integration) => ({ query: ({ pluginId, ...integration }) => ({
url: getProxyApiUrl('/alert_receive_channels/'), url: getProxyApiUrl('/alert_receive_channels/', pluginId),
data: integration, data: integration,
method: 'POST', method: 'POST',
showErrorAlert: true, showErrorAlert: true,
}), }),
invalidatesTags: ['OnCallIntegrations'], invalidatesTags: ['OnCallIntegrations'],
}), }),
features: build.query<OnCallFeature[], void>({ features: build.query<OnCallFeature[], { pluginId: string }>({
query: () => ({ query: ({ pluginId }) => ({
url: getProxyApiUrl('/features/'), url: getProxyApiUrl('/features/', pluginId),
showErrorAlert: false, showErrorAlert: false,
}), }),
}), }),
onCallConfigChecks: build.query<OnCallConfigChecks, void>({ onCallConfigChecks: build.query<OnCallConfigChecks, { pluginId: string }>({
query: () => ({ query: ({ pluginId }) => ({
url: getProxyApiUrl('/organization/config-checks/'), url: getProxyApiUrl('/organization/config-checks/', pluginId),
showErrorAlert: false, showErrorAlert: false,
}), }),
}), }),
@@ -1,8 +1,8 @@
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { Button, LinkButton, Menu, Tooltip } from '@grafana/ui'; import { Button, LinkButton, Menu, Tooltip } from '@grafana/ui';
import { usePluginBridge } from '../../hooks/usePluginBridge'; import { useIrmPlugin } from '../../hooks/usePluginBridge';
import { getIrmIfPresentOrIncidentPluginId } from '../../utils/config'; import { SupportedPlugin } from '../../types/pluginBridges';
import { createBridgeURL } from '../PluginBridge'; import { createBridgeURL } from '../PluginBridge';
interface Props { interface Props {
@@ -11,20 +11,18 @@ interface Props {
url?: string; url?: string;
} }
const pluginId = getIrmIfPresentOrIncidentPluginId();
export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: Props) => { export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: Props) => {
const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident);
const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', { const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', {
title, title,
severity, severity,
url, url,
}); });
const { loading, installed, settings } = usePluginBridge(pluginId);
return ( return (
<> <>
{loading === true && ( {loading && (
<Button icon="fire" size="sm" type="button" disabled> <Button icon="fire" size="sm" type="button" disabled>
<Trans i18nKey="alerting.declare-incident-button.declare-incident">Declare Incident</Trans> <Trans i18nKey="alerting.declare-incident-button.declare-incident">Declare Incident</Trans>
</Button> </Button>
@@ -51,17 +49,17 @@ export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: P
}; };
export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => { export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => {
const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident);
const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', { const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', {
title, title,
severity, severity,
url, url,
}); });
const { loading, installed, settings } = usePluginBridge(pluginId);
return ( return (
<> <>
{loading === true && ( {loading && (
<Menu.Item <Menu.Item
label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')} label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')}
icon="fire" icon="fire"
@@ -18,10 +18,10 @@ import { getAPINamespace } from '../../../../../api/utils';
import { alertmanagerApi } from '../../api/alertmanagerApi'; import { alertmanagerApi } from '../../api/alertmanagerApi';
import { onCallApi } from '../../api/onCallApi'; import { onCallApi } from '../../api/onCallApi';
import { useAsync } from '../../hooks/useAsync'; import { useAsync } from '../../hooks/useAsync';
import { usePluginBridge } from '../../hooks/usePluginBridge'; import { useIrmPlugin } from '../../hooks/usePluginBridge';
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig'; import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
import { addReceiverAction, deleteReceiverAction, updateReceiverAction } from '../../reducers/alertmanager/receivers'; import { addReceiverAction, deleteReceiverAction, updateReceiverAction } from '../../reducers/alertmanager/receivers';
import { getIrmIfPresentOrOnCallPluginId } from '../../utils/config'; import { SupportedPlugin } from '../../types/pluginBridges';
import { enhanceContactPointsWithMetadata } from './utils'; import { enhanceContactPointsWithMetadata } from './utils';
@@ -61,8 +61,8 @@ const defaultOptions = {
* Otherwise, returns no data * Otherwise, returns no data
*/ */
const useOnCallIntegrations = ({ skip }: Skippable = {}) => { const useOnCallIntegrations = ({ skip }: Skippable = {}) => {
const { installed, loading } = usePluginBridge(getIrmIfPresentOrOnCallPluginId()); const { pluginId, installed, loading } = useIrmPlugin(SupportedPlugin.OnCall);
const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery(undefined, { skip: skip || !installed }); const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery({ pluginId }, { skip: skip || !installed });
return useMemo(() => { return useMemo(() => {
if (installed) { if (installed) {
@@ -126,6 +126,10 @@ export const useGrafanaContactPoints = ({
}: GrafanaFetchOptions & Skippable = {}) => { }: GrafanaFetchOptions & Skippable = {}) => {
const namespace = getAPINamespace(); const namespace = getAPINamespace();
const potentiallySkip = { skip }; const potentiallySkip = { skip };
// Get the IRM/OnCall plugin information
const irmOrOnCallPlugin = useIrmPlugin(SupportedPlugin.OnCall);
const onCallResponse = useOnCallIntegrations(potentiallySkip); const onCallResponse = useOnCallIntegrations(potentiallySkip);
const alertNotifiers = useGrafanaNotifiersQuery(undefined, potentiallySkip); const alertNotifiers = useGrafanaNotifiersQuery(undefined, potentiallySkip);
const contactPointsListResponse = useK8sContactPoints({ namespace }, potentiallySkip); const contactPointsListResponse = useK8sContactPoints({ namespace }, potentiallySkip);
@@ -158,6 +162,7 @@ export const useGrafanaContactPoints = ({
status: contactPointsStatusResponse.data, status: contactPointsStatusResponse.data,
notifiers: alertNotifiers.data, notifiers: alertNotifiers.data,
onCallIntegrations: onCallResponse?.data, onCallIntegrations: onCallResponse?.data,
onCallPluginId: irmOrOnCallPlugin.pluginId,
contactPoints: contactPointsListResponse.data || [], contactPoints: contactPointsListResponse.data || [],
alertmanagerConfiguration: alertmanagerConfigResponse.data, alertmanagerConfiguration: alertmanagerConfigResponse.data,
}); });
@@ -172,6 +177,7 @@ export const useGrafanaContactPoints = ({
contactPointsListResponse, contactPointsListResponse,
contactPointsStatusResponse, contactPointsStatusResponse,
onCallResponse, onCallResponse,
irmOrOnCallPlugin.pluginId,
]); ]);
}; };
@@ -15,6 +15,7 @@ import {
} from 'app/plugins/datasource/alertmanager/types'; } from 'app/plugins/datasource/alertmanager/types';
import { OnCallIntegrationDTO } from '../../api/onCallApi'; import { OnCallIntegrationDTO } from '../../api/onCallApi';
import { SupportedPlugin } from '../../types/pluginBridges';
import { extractReceivers } from '../../utils/receivers'; import { extractReceivers } from '../../utils/receivers';
import { routeAdapter } from '../../utils/routeAdapter'; import { routeAdapter } from '../../utils/routeAdapter';
import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall';
@@ -113,7 +114,8 @@ export interface ContactPointWithMetadata extends GrafanaManagedContactPoint {
type EnhanceContactPointsArgs = { type EnhanceContactPointsArgs = {
status?: ReceiversStateDTO[]; status?: ReceiversStateDTO[];
notifiers?: NotifierDTO[]; notifiers?: NotifierDTO[];
onCallIntegrations?: OnCallIntegrationDTO[] | undefined | null; onCallIntegrations?: OnCallIntegrationDTO[];
onCallPluginId?: SupportedPlugin;
contactPoints: Receiver[]; contactPoints: Receiver[];
alertmanagerConfiguration?: AlertManagerCortexConfig; alertmanagerConfiguration?: AlertManagerCortexConfig;
}; };
@@ -130,6 +132,7 @@ export function enhanceContactPointsWithMetadata({
status = [], status = [],
notifiers = [], notifiers = [],
onCallIntegrations, onCallIntegrations,
onCallPluginId,
contactPoints, contactPoints,
alertmanagerConfiguration, alertmanagerConfiguration,
}: EnhanceContactPointsArgs): ContactPointWithMetadata[] { }: EnhanceContactPointsArgs): ContactPointWithMetadata[] {
@@ -162,7 +165,7 @@ export function enhanceContactPointsWithMetadata({
[RECEIVER_META_KEY]: getNotifierMetadata(notifiers, receiver), [RECEIVER_META_KEY]: getNotifierMetadata(notifiers, receiver),
// if OnCall plugin is installed, we'll add it to the receiver's plugin metadata // if OnCall plugin is installed, we'll add it to the receiver's plugin metadata
[RECEIVER_PLUGIN_META_KEY]: isOnCallReceiver [RECEIVER_PLUGIN_META_KEY]: isOnCallReceiver
? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration)) ? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration), onCallPluginId)
: undefined, : undefined,
}; };
}), }),
@@ -6,12 +6,12 @@ import { t } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime'; import { isFetchError } from '@grafana/runtime';
import { Badge } from '@grafana/ui'; import { Badge } from '@grafana/ui';
import { NotifierDTO } from 'app/features/alerting/unified/types/alerting'; 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 { useAppNotification } from '../../../../../../../core/copy/appNotification';
import { Receiver } from '../../../../../../../plugins/datasource/alertmanager/types'; import { Receiver } from '../../../../../../../plugins/datasource/alertmanager/types';
import { ONCALL_INTEGRATION_V2_FEATURE, onCallApi } from '../../../../api/onCallApi'; 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 { option } from '../../../../utils/notifier-types';
import { GRAFANA_ONCALL_INTEGRATION_TYPE, ReceiverTypes } from './onCall'; import { GRAFANA_ONCALL_INTEGRATION_TYPE, ReceiverTypes } from './onCall';
@@ -39,16 +39,17 @@ enum OnCallIntegrationStatus {
function useOnCallPluginStatus() { function useOnCallPluginStatus() {
const { const {
pluginId,
installed: isOnCallEnabled, installed: isOnCallEnabled,
loading: isPluginBridgeLoading, loading: isPluginBridgeLoading,
error: pluginError, error: pluginError,
} = usePluginBridge(getIrmIfPresentOrOnCallPluginId()); } = useIrmPlugin(SupportedPlugin.OnCall);
const { const {
data: onCallFeatures = [], data: onCallFeatures = [],
error: onCallFeaturesError, error: onCallFeaturesError,
isLoading: isOnCallFeaturesLoading, isLoading: isOnCallFeaturesLoading,
} = onCallApi.endpoints.features.useQuery(undefined, { skip: !isOnCallEnabled }); } = onCallApi.endpoints.features.useQuery({ pluginId }, { skip: !isOnCallEnabled });
const integrationStatus = useMemo((): OnCallIntegrationStatus => { const integrationStatus = useMemo((): OnCallIntegrationStatus => {
if (!isOnCallEnabled) { if (!isOnCallEnabled) {
@@ -67,6 +68,7 @@ function useOnCallPluginStatus() {
); );
return { return {
pluginId,
isOnCallEnabled, isOnCallEnabled,
integrationStatus, integrationStatus,
isAlertingV2IntegrationEnabled, isAlertingV2IntegrationEnabled,
@@ -78,8 +80,14 @@ function useOnCallPluginStatus() {
export function useOnCallIntegration() { export function useOnCallIntegration() {
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
const { isOnCallEnabled, integrationStatus, isAlertingV2IntegrationEnabled, isOnCallStatusLoading, onCallError } = const {
useOnCallPluginStatus(); pluginId,
isOnCallEnabled,
integrationStatus,
isAlertingV2IntegrationEnabled,
isOnCallStatusLoading,
onCallError,
} = useOnCallPluginStatus();
const { useCreateIntegrationMutation, useGrafanaOnCallIntegrationsQuery, useLazyValidateIntegrationNameQuery } = const { useCreateIntegrationMutation, useGrafanaOnCallIntegrationsQuery, useLazyValidateIntegrationNameQuery } =
onCallApi; onCallApi;
@@ -91,13 +99,13 @@ export function useOnCallIntegration() {
data: grafanaOnCallIntegrations = [], data: grafanaOnCallIntegrations = [],
isLoading: isLoadingOnCallIntegrations, isLoading: isLoadingOnCallIntegrations,
isError: isIntegrationsQueryError, isError: isIntegrationsQueryError,
} = useGrafanaOnCallIntegrationsQuery(undefined, { skip: !isAlertingV2IntegrationEnabled }); } = useGrafanaOnCallIntegrationsQuery({ pluginId }, { skip: !isAlertingV2IntegrationEnabled });
const onCallFormValidators = useMemo(() => { const onCallFormValidators = useMemo(() => {
return { return {
integration_name: async (value: string) => { integration_name: async (value: string) => {
try { try {
await validateIntegrationNameQuery(value).unwrap(); await validateIntegrationNameQuery({ name: value, pluginId }).unwrap();
return true; return true;
} catch (error) { } catch (error) {
if (isFetchError(error) && error.status === 409) { 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'); : 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( const extendOnCallReceivers = useCallback(
(receiver: Receiver): Receiver => { (receiver: Receiver): Receiver => {
@@ -159,6 +167,7 @@ export function useOnCallIntegration() {
const createNewOnCallIntegrationJobs = newOnCallIntegrations.map(async (c) => { const createNewOnCallIntegrationJobs = newOnCallIntegrations.map(async (c) => {
const newIntegration = await createIntegrationMutation({ const newIntegration = await createIntegrationMutation({
pluginId,
integration: GRAFANA_ONCALL_INTEGRATION_TYPE, integration: GRAFANA_ONCALL_INTEGRATION_TYPE,
verbal_name: c.settings[OnCallIntegrationSetting.IntegrationName], verbal_name: c.settings[OnCallIntegrationSetting.IntegrationName],
}).unwrap(); }).unwrap();
@@ -180,7 +189,7 @@ export function useOnCallIntegration() {
}); });
}); });
}, },
[isAlertingV2IntegrationEnabled, createIntegrationMutation] [isAlertingV2IntegrationEnabled, createIntegrationMutation, pluginId]
); );
const extendOnCallNotifierFeatures = useCallback( const extendOnCallNotifierFeatures = useCallback(
@@ -1,6 +1,9 @@
import { useMemo } from 'react';
import { GrafanaManagedReceiverConfig } from '../../../../../../plugins/datasource/alertmanager/types'; import { GrafanaManagedReceiverConfig } from '../../../../../../plugins/datasource/alertmanager/types';
import { OnCallIntegrationDTO } from '../../../api/onCallApi'; 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 { createBridgeURL } from '../../PluginBridge';
import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from './types'; import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from './types';
@@ -13,20 +16,34 @@ export interface ReceiverPluginMetadata {
warning?: string; warning?: string;
} }
const onCallReceiverICon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[getIrmIfPresentOrOnCallPluginId()]; export function useOnCallMetadata(
const onCallReceiverTitle = 'Grafana OnCall';
export const onCallReceiverMeta: ReceiverPluginMetadata = {
title: onCallReceiverTitle,
icon: onCallReceiverICon,
};
export function getOnCallMetadata(
onCallIntegrations: OnCallIntegrationDTO[] | undefined | null, onCallIntegrations: OnCallIntegrationDTO[] | undefined | null,
receiver: GrafanaManagedReceiverConfig, receiver: GrafanaManagedReceiverConfig,
hasAlertManagerConfigData = true hasAlertManagerConfigData = true
): ReceiverPluginMetadata { ): 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) { if (!hasAlertManagerConfigData) {
return onCallReceiverMeta; return onCallReceiverMeta;
@@ -57,7 +74,7 @@ export function getOnCallMetadata(
...onCallReceiverMeta, ...onCallReceiverMeta,
description: matchingOnCallIntegration?.display_name, description: matchingOnCallIntegration?.display_name,
externalUrl: matchingOnCallIntegration externalUrl: matchingOnCallIntegration
? createBridgeURL(getIrmIfPresentOrOnCallPluginId(), `/integrations/${matchingOnCallIntegration.value}`) ? createBridgeURL(pluginId, `/integrations/${matchingOnCallIntegration.value}`)
: undefined, : undefined,
warning: matchingOnCallIntegration ? undefined : `${pluginName} Integration no longer exists`, warning: matchingOnCallIntegration ? undefined : `${pluginName} Integration no longer exists`,
}; };
@@ -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]);
});
});
@@ -4,6 +4,8 @@ import { PluginMeta } from '@grafana/data';
import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { PluginID } from '../components/PluginBridge'; import { PluginID } from '../components/PluginBridge';
import { SupportedPlugin } from '../types/pluginBridges';
interface PluginBridgeHookResponse { interface PluginBridgeHookResponse {
loading: boolean; loading: boolean;
installed?: boolean; installed?: boolean;
@@ -14,17 +16,54 @@ interface PluginBridgeHookResponse {
export function usePluginBridge(plugin: PluginID): PluginBridgeHookResponse { export function usePluginBridge(plugin: PluginID): PluginBridgeHookResponse {
const { loading, error, value } = useAsync(() => getPluginSettings(plugin, { showErrorAlert: false })); const { loading, error, value } = useAsync(() => getPluginSettings(plugin, { showErrorAlert: false }));
const installed = value && !error && !loading; if (loading) {
const enabled = value?.enabled;
const isLoading = loading && !value;
if (isLoading) {
return { loading: true }; return { loading: true };
} }
if (!installed || !enabled) { if (error) {
return { loading: false, installed: false }; 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,
};
} }
@@ -1,6 +1,5 @@
import { type DefaultBodyType, HttpResponse, HttpResponseResolver, PathParams, http } from 'msw'; import { type DefaultBodyType, HttpResponse, HttpResponseResolver, PathParams, http } from 'msw';
import { config } from '@grafana/runtime';
import server from '@grafana/test-utils/server'; import server from '@grafana/test-utils/server';
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks'; import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
import { import {
@@ -10,10 +9,7 @@ import {
} from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers'; } from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers';
import { getFolderHandler } from 'app/features/alerting/unified/mocks/server/handlers/folders'; 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 { listNamespacedTimeIntervalHandler } from 'app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s';
import { import { getDisabledPluginHandler } from 'app/features/alerting/unified/mocks/server/handlers/plugins';
getDisabledPluginHandler,
getPluginMissingHandler,
} from 'app/features/alerting/unified/mocks/server/handlers/plugins';
import { import {
ALERTING_API_SERVER_BASE_URL, ALERTING_API_SERVER_BASE_URL,
getK8sResponse, getK8sResponse,
@@ -212,12 +208,6 @@ export function setGrafanaPromRules(groups: GrafanaPromRuleGroupDTO[]) {
server.use(http.get(`/api/prometheus/grafana/api/v1/rules`, paginatedHandlerFor(groups))); 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 */ /** Make a plugin respond with `enabled: false`, as if its installed but disabled */
export const disablePlugin = (pluginId: SupportedPlugin) => { export const disablePlugin = (pluginId: SupportedPlugin) => {
clearPluginSettingsCache(pluginId); clearPluginSettingsCache(pluginId);
@@ -1,14 +1,6 @@
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { pluginMeta, pluginMetaToPluginConfig } from '../testSetup/plugins'; import { checkEvaluationIntervalGlobalLimit } from './config';
import { SupportedPlugin } from '../types/pluginBridges';
import {
checkEvaluationIntervalGlobalLimit,
getIrmIfPresentOrIncidentPluginId,
getIrmIfPresentOrOnCallPluginId,
getIsIrmPluginPresent,
} from './config';
describe('checkEvaluationIntervalGlobalLimit', () => { describe('checkEvaluationIntervalGlobalLimit', () => {
it('should NOT exceed limit if evaluate every is not valid duration', () => { it('should NOT exceed limit if evaluate every is not valid duration', () => {
@@ -59,48 +51,3 @@ describe('checkEvaluationIntervalGlobalLimit', () => {
expect(exceedsLimit).toBe(false); 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);
});
});
@@ -1,8 +1,6 @@
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { SupportedPlugin } from '../types/pluginBridges';
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time'; import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> { export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
@@ -28,15 +26,3 @@ export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: str
return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit }; 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;
}
@@ -1,6 +1,6 @@
import { incidentsApi } from 'app/features/alerting/unified/api/incidentsApi'; import { incidentsApi } from 'app/features/alerting/unified/api/incidentsApi';
import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge'; import { useIrmPlugin } from 'app/features/alerting/unified/hooks/usePluginBridge';
import { getIrmIfPresentOrIncidentPluginId } from 'app/features/alerting/unified/utils/config'; import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
interface IncidentsPluginConfig { interface IncidentsPluginConfig {
isInstalled: boolean; isInstalled: boolean;
@@ -10,11 +10,13 @@ interface IncidentsPluginConfig {
} }
export function useGetIncidentPluginConfig(): IncidentsPluginConfig { export function useGetIncidentPluginConfig(): IncidentsPluginConfig {
const { installed: incidentPluginInstalled, loading: loadingPluginSettings } = usePluginBridge( const {
getIrmIfPresentOrIncidentPluginId() pluginId,
); installed: incidentPluginInstalled,
loading: loadingPluginSettings,
} = useIrmPlugin(SupportedPlugin.Incident);
const { data: incidentsConfig, isLoading: loadingPluginConfig } = const { data: incidentsConfig, isLoading: loadingPluginConfig } =
incidentsApi.endpoints.getIncidentsPluginConfig.useQuery(); incidentsApi.endpoints.getIncidentsPluginConfig.useQuery({ pluginId });
return { return {
isInstalled: incidentPluginInstalled ?? false, isInstalled: incidentPluginInstalled ?? false,
@@ -1,26 +1,34 @@
import { onCallApi } from 'app/features/alerting/unified/api/onCallApi'; import { onCallApi } from 'app/features/alerting/unified/api/onCallApi';
import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge'; import { useIrmPlugin } from 'app/features/alerting/unified/hooks/usePluginBridge';
import { getIrmIfPresentOrOnCallPluginId } from 'app/features/alerting/unified/utils/config'; import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
export function useGetOnCallIntegrations() { export function useGetOnCallIntegrations() {
const { installed: onCallPluginInstalled } = usePluginBridge(getIrmIfPresentOrOnCallPluginId()); const { pluginId, installed: onCallPluginInstalled } = useIrmPlugin(SupportedPlugin.OnCall);
const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(undefined, { const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(
skip: !onCallPluginInstalled, { pluginId },
refetchOnFocus: true, {
refetchOnReconnect: true, skip: !onCallPluginInstalled,
refetchOnMountOrArgChange: true, refetchOnFocus: true,
}); refetchOnReconnect: true,
refetchOnMountOrArgChange: true,
}
);
return onCallIntegrations ?? []; return onCallIntegrations ?? [];
} }
function useGetOnCallConfigurationChecks() { function useGetOnCallConfigurationChecks() {
const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery(undefined, { const { pluginId } = useIrmPlugin(SupportedPlugin.OnCall);
refetchOnFocus: true,
refetchOnReconnect: true, const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery(
refetchOnMountOrArgChange: true, { pluginId },
}); {
refetchOnFocus: true,
refetchOnReconnect: true,
refetchOnMountOrArgChange: true,
}
);
return { return {
isLoading, isLoading,