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

View File

@@ -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

View File

@@ -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<IncidentsPluginConfigDto, void>({
query: () => ({
url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker'),
getIncidentsPluginConfig: build.query<IncidentsPluginConfigDto, { pluginId: string }>({
query: ({ pluginId }) => ({
url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker', pluginId),
data: {},
method: 'POST',
showErrorAlert: false,

View File

@@ -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/'
);
});

View File

@@ -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<OnCallIntegrationDTO[], void>({
query: () => ({
url: getProxyApiUrl('/alert_receive_channels/'),
grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], { pluginId: string }>({
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<boolean, string>({
query: (name) => ({
url: getProxyApiUrl('/alert_receive_channels/validate_name/'),
validateIntegrationName: build.query<boolean, { name: string; pluginId: string }>({
query: ({ name, pluginId }) => ({
url: getProxyApiUrl('/alert_receive_channels/validate_name/', pluginId),
params: { verbal_name: name },
showErrorAlert: false,
}),
}),
createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO>({
query: (integration) => ({
url: getProxyApiUrl('/alert_receive_channels/'),
createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO & { pluginId: string }>({
query: ({ pluginId, ...integration }) => ({
url: getProxyApiUrl('/alert_receive_channels/', pluginId),
data: integration,
method: 'POST',
showErrorAlert: true,
}),
invalidatesTags: ['OnCallIntegrations'],
}),
features: build.query<OnCallFeature[], void>({
query: () => ({
url: getProxyApiUrl('/features/'),
features: build.query<OnCallFeature[], { pluginId: string }>({
query: ({ pluginId }) => ({
url: getProxyApiUrl('/features/', pluginId),
showErrorAlert: false,
}),
}),
onCallConfigChecks: build.query<OnCallConfigChecks, void>({
query: () => ({
url: getProxyApiUrl('/organization/config-checks/'),
onCallConfigChecks: build.query<OnCallConfigChecks, { pluginId: string }>({
query: ({ pluginId }) => ({
url: getProxyApiUrl('/organization/config-checks/', pluginId),
showErrorAlert: false,
}),
}),

View File

@@ -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 && (
<Button icon="fire" size="sm" type="button" disabled>
<Trans i18nKey="alerting.declare-incident-button.declare-incident">Declare Incident</Trans>
</Button>
@@ -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 && (
<Menu.Item
label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')}
icon="fire"

View File

@@ -18,10 +18,10 @@ import { getAPINamespace } from '../../../../../api/utils';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { onCallApi } from '../../api/onCallApi';
import { useAsync } from '../../hooks/useAsync';
import { usePluginBridge } from '../../hooks/usePluginBridge';
import { useIrmPlugin } from '../../hooks/usePluginBridge';
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
import { addReceiverAction, deleteReceiverAction, updateReceiverAction } from '../../reducers/alertmanager/receivers';
import { getIrmIfPresentOrOnCallPluginId } from '../../utils/config';
import { SupportedPlugin } from '../../types/pluginBridges';
import { enhanceContactPointsWithMetadata } from './utils';
@@ -61,8 +61,8 @@ const defaultOptions = {
* Otherwise, returns no data
*/
const useOnCallIntegrations = ({ skip }: Skippable = {}) => {
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,
]);
};

View File

@@ -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,
};
}),

View File

@@ -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(

View File

@@ -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`,
};

View File

@@ -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]);
});
});

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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<DataSourceInstanceSettings<DataSourceJsonData>> {
@@ -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;
}

View File

@@ -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,

View File

@@ -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,