From 12aac6fcfd30de8d2960ddce53ff666022e5bc7a Mon Sep 17 00:00:00 2001
From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com>
Date: Wed, 21 Jan 2026 16:08:09 +0100
Subject: [PATCH] Alerting: Filter out imported contact points from simplified
routing dropdown (#116408)
* filter out imported contact points from simplified routing dropdown
- Add filter prop to ContactPointSelector in @grafana/alerting package
- Filter out contact points with 'converted_prometheus' provenance in simplified routing
- Add tests for the filter functionality in both package and app
* Use canUse annotation to filter contact points in simplified routing
- Add K8sAnnotations.CanUse constant for 'grafana.com/canUse' annotation
- Update ContactPointSelector to filter by canUse annotation instead of provenance
- Update receivers mock to include canUse annotation (matches backend behavior)
- Update tests to use canUse annotation instead of provenance check
* Update useContactPoints snapshots with canUse annotation
* address pr feedback
* extract filter to a new utility function + tests
* fix test
---
.../ContactPointSelector.test.scenario.ts | 46 +++++++++++++
.../ContactPointSelector.test.tsx | 55 +++++++++++++++-
.../ContactPointSelector.tsx | 27 +++++---
.../src/grafana/contactPoints/utils.test.ts | 34 +++++++++-
.../src/grafana/contactPoints/utils.ts | 16 +++++
.../useContactPoints.test.tsx.snap | 10 +++
.../SimplifiedRuleEditor.test.tsx | 65 +++++++++++++++++++
.../server/handlers/k8s/receivers.k8s.ts | 3 +
.../alerting/unified/utils/k8s/constants.ts | 3 +
9 files changed, 247 insertions(+), 12 deletions(-)
diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts
index 9812d5c5d28..bbc33e570e7 100644
--- a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts
+++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts
@@ -2,6 +2,7 @@ import { HttpResponse } from 'msw';
import {
ContactPointFactory,
+ ContactPointMetadataAnnotationsFactory,
EmailIntegrationFactory,
ListReceiverApiResponseFactory,
SlackIntegrationFactory,
@@ -33,3 +34,48 @@ export const simpleContactPointsList = ListReceiverApiResponseFactory.build({
export const simpleContactPointsListScenario = [listReceiverHandler(simpleContactPointsList)];
export const withErrorScenario = [listReceiverHandler(() => new HttpResponse(null, { status: 500 }))];
+
+// Contact points with different canUse values for testing filter functionality
+export const contactPointsListWithUnusableItems = ListReceiverApiResponseFactory.build({
+ items: [
+ // Regular contact point (canUse: true)
+ ContactPointFactory.build({
+ spec: {
+ title: 'regular-contact-point',
+ integrations: [EmailIntegrationFactory.build()],
+ },
+ metadata: {
+ annotations: ContactPointMetadataAnnotationsFactory.build({
+ 'grafana.com/provenance': '',
+ }),
+ },
+ }),
+ // Imported contact point (canUse: false)
+ ContactPointFactory.build({
+ spec: {
+ title: 'imported-contact-point',
+ integrations: [SlackIntegrationFactory.build()],
+ },
+ metadata: {
+ annotations: ContactPointMetadataAnnotationsFactory.build({
+ 'grafana.com/provenance': 'converted_prometheus',
+ 'grafana.com/canUse': 'false',
+ }),
+ },
+ }),
+ // API provisioned contact point (canUse: true)
+ ContactPointFactory.build({
+ spec: {
+ title: 'api-provisioned-contact-point',
+ integrations: [EmailIntegrationFactory.build()],
+ },
+ metadata: {
+ annotations: ContactPointMetadataAnnotationsFactory.build({
+ 'grafana.com/provenance': 'api',
+ }),
+ },
+ }),
+ ],
+});
+
+export const contactPointsListWithUnusableItemsScenario = [listReceiverHandler(contactPointsListWithUnusableItems)];
diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.tsx
index 7da4dba2471..6996c64bf33 100644
--- a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.tsx
+++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.tsx
@@ -4,7 +4,12 @@ import { render, screen, within } from '../../../../../tests/test-utils';
import { getContactPointDescription } from '../../utils';
import { ContactPointSelector } from './ContactPointSelector';
-import { simpleContactPointsList, simpleContactPointsListScenario } from './ContactPointSelector.test.scenario';
+import {
+ contactPointsListWithUnusableItems,
+ contactPointsListWithUnusableItemsScenario,
+ simpleContactPointsList,
+ simpleContactPointsListScenario,
+} from './ContactPointSelector.test.scenario';
const server = setupMockServer();
@@ -31,8 +36,8 @@ describe('listing contact points', () => {
it('should show a sorted list of contact points', async () => {
const onChangeHandler = jest.fn();
- // render the contact point selector
- const { user } = render();
+ // render the contact point selector with includeUnusable=true to show all
+ const { user } = render();
await user.click(screen.getByRole('combobox'));
// make sure all options are rendered
@@ -52,3 +57,47 @@ describe('listing contact points', () => {
expect(onChangeHandler).toHaveBeenCalledWith(firstContactPoint);
});
});
+
+describe('filtering out unusable contact points', () => {
+ beforeEach(() => {
+ server.use(...contactPointsListWithUnusableItemsScenario);
+ });
+
+ it('should filter out unusable contact points by default', async () => {
+ const onChangeHandler = jest.fn();
+
+ // Default behavior: filter out unusable contact points
+ const { user } = render();
+ await user.click(screen.getByRole('combobox'));
+
+ // Only usable contact points should be shown (2 out of 3)
+ const options = await screen.findAllByRole('option');
+ expect(options).toHaveLength(2);
+
+ // The non-usable contact point should NOT be in the list
+ const nonUsableContactPoint = contactPointsListWithUnusableItems.items.find(
+ (cp) => cp.metadata?.annotations?.['grafana.com/canUse'] === 'false'
+ );
+ expect(
+ screen.queryByRole('option', { name: new RegExp(nonUsableContactPoint!.spec.title) })
+ ).not.toBeInTheDocument();
+
+ // The usable contact points should be in the list
+ const usableContactPoints = contactPointsListWithUnusableItems.items.filter(
+ (cp) => cp.metadata?.annotations?.['grafana.com/canUse'] === 'true'
+ );
+ for (const item of usableContactPoints) {
+ expect(await screen.findByRole('option', { name: new RegExp(item.spec.title) })).toBeInTheDocument();
+ }
+ });
+
+ it('should show all contact points when includeUnusable is true', async () => {
+ const onChangeHandler = jest.fn();
+
+ const { user } = render();
+ await user.click(screen.getByRole('combobox'));
+
+ // All contact points should be shown
+ expect(await screen.findAllByRole('option')).toHaveLength(contactPointsListWithUnusableItems.items.length);
+ });
+});
diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx
index e6e8702b57e..81b4741380f 100644
--- a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx
+++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx
@@ -4,19 +4,29 @@ import { Combobox, ComboboxOption } from '@grafana/ui';
import type { ContactPoint } from '../../../api/notifications/v0alpha1/types';
import { useListContactPoints } from '../../hooks/v0alpha1/useContactPoints';
-import { getContactPointDescription } from '../../utils';
+import { getContactPointDescription, isUsableContactPoint } from '../../utils';
import { CustomComboBoxProps } from './ComboBox.types';
const collator = new Intl.Collator('en', { sensitivity: 'accent' });
-export type ContactPointSelectorProps = CustomComboBoxProps;
+export type ContactPointSelectorProps = CustomComboBoxProps & {
+ /**
+ * Whether to include contact points that are not usable (e.g., imported from external sources).
+ * Unusable contact points have the `grafana.com/canUse` annotation set to `false`.
+ * @default false
+ */
+ includeUnusable?: boolean;
+};
/**
- * Contact Point Combobox which lists all available contact points
- * @TODO make ComboBox accept a ReactNode so we can use icons and such
+ * Contact Point Combobox which lists all available contact points.
+ * By default, only shows contact points that can be used (have `grafana.com/canUse: true`).
+ * Set `includeUnusable` to `true` to show all contact points including imported ones.
*/
function ContactPointSelector(props: ContactPointSelectorProps) {
+ const { includeUnusable = false, ...comboboxProps } = props;
+
const { currentData: contactPoints, isLoading } = useListContactPoints(
{},
{ refetchOnFocus: true, refetchOnMountOrArgChange: true }
@@ -25,6 +35,7 @@ function ContactPointSelector(props: ContactPointSelectorProps) {
// Create a mapping of options with their corresponding contact points
const contactPointOptions = chain(contactPoints?.items)
.toArray()
+ .filter((contactPoint) => includeUnusable || isUsableContactPoint(contactPoint))
.map((contactPoint) => ({
option: {
label: contactPoint.spec.title,
@@ -39,8 +50,8 @@ function ContactPointSelector(props: ContactPointSelectorProps) {
const options = contactPointOptions.map((item) => item.option);
const handleChange = (selectedOption: ComboboxOption | null) => {
- if (selectedOption == null && props.isClearable) {
- props.onChange(null);
+ if (selectedOption == null && comboboxProps.isClearable) {
+ comboboxProps.onChange(null);
return;
}
@@ -50,11 +61,11 @@ function ContactPointSelector(props: ContactPointSelectorProps) {
return;
}
- props.onChange(matchedOption.contactPoint);
+ comboboxProps.onChange(matchedOption.contactPoint);
}
};
- return ;
+ return ;
}
export { ContactPointSelector };
diff --git a/packages/grafana-alerting/src/grafana/contactPoints/utils.test.ts b/packages/grafana-alerting/src/grafana/contactPoints/utils.test.ts
index 547c63398f7..734684c4ab4 100644
--- a/packages/grafana-alerting/src/grafana/contactPoints/utils.test.ts
+++ b/packages/grafana-alerting/src/grafana/contactPoints/utils.test.ts
@@ -1,11 +1,12 @@
import {
ContactPointFactory,
+ ContactPointMetadataAnnotationsFactory,
EmailIntegrationFactory,
GenericIntegrationFactory,
SlackIntegrationFactory,
} from '../api/notifications/v0alpha1/mocks/fakes/Receivers';
-import { getContactPointDescription } from './utils';
+import { getContactPointDescription, isUsableContactPoint } from './utils';
describe('getContactPointDescription', () => {
it('should show description for single integration', () => {
@@ -47,3 +48,34 @@ describe('getContactPointDescription', () => {
expect(getContactPointDescription(contactPoint)).toBe('generic');
});
});
+
+describe('isUsableContactPoint', () => {
+ it('should return true when canUse annotation is true', () => {
+ const contactPoint = ContactPointFactory.build({
+ metadata: {
+ annotations: ContactPointMetadataAnnotationsFactory.build({
+ 'grafana.com/canUse': 'true',
+ }),
+ },
+ });
+ expect(isUsableContactPoint(contactPoint)).toBe(true);
+ });
+
+ it('should return false when canUse annotation is false', () => {
+ const contactPoint = ContactPointFactory.build({
+ metadata: {
+ annotations: ContactPointMetadataAnnotationsFactory.build({
+ 'grafana.com/canUse': 'false',
+ }),
+ },
+ });
+ expect(isUsableContactPoint(contactPoint)).toBe(false);
+ });
+
+ it('should return false when canUse annotation is missing', () => {
+ const contactPoint = ContactPointFactory.build();
+ // Remove the canUse annotation to simulate it being missing
+ delete contactPoint.metadata.annotations?.['grafana.com/canUse'];
+ expect(isUsableContactPoint(contactPoint)).toBe(false);
+ });
+});
diff --git a/packages/grafana-alerting/src/grafana/contactPoints/utils.ts b/packages/grafana-alerting/src/grafana/contactPoints/utils.ts
index e6748c657ff..c86fa3cf335 100644
--- a/packages/grafana-alerting/src/grafana/contactPoints/utils.ts
+++ b/packages/grafana-alerting/src/grafana/contactPoints/utils.ts
@@ -4,6 +4,22 @@ import { Receiver } from '@grafana/api-clients/rtkq/notifications.alerting/v0alp
import { ContactPoint } from '../api/notifications/v0alpha1/types';
+// Annotation key that indicates whether a contact point can be used in routes and rules
+const CAN_USE_ANNOTATION = 'grafana.com/canUse';
+
+/**
+ * Checks if a contact point can be used in routes and rules.
+ * Contact points that are imported from external sources (e.g., Prometheus Alertmanager)
+ * have the `grafana.com/canUse` annotation set to `false` and cannot be used.
+ *
+ * @param contactPoint - The ContactPoint object to check
+ * @returns `true` if the contact point can be used, `false` otherwise
+ */
+export function isUsableContactPoint(contactPoint: ContactPoint | Receiver): boolean {
+ const canUse = contactPoint.metadata?.annotations?.[CAN_USE_ANNOTATION];
+ return canUse === 'true';
+}
+
/**
* Generates a human-readable description of a ContactPoint by summarizing its integrations.
* If the ContactPoint has no integrations, it returns an empty placeholder text.
diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap
index 7524d3ba37a..659bad2b829 100644
--- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap
+++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap
@@ -35,6 +35,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "false",
"grafana.com/access/canWrite": "false",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "1",
"grafana.com/inUse/rules": "1",
"grafana.com/provenance": "none",
@@ -85,6 +86,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canWrite": "true",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "none",
@@ -121,6 +123,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canWrite": "true",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "none",
@@ -163,6 +166,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canWrite": "true",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "api",
@@ -235,6 +239,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canWrite": "true",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "none",
@@ -286,6 +291,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "false",
"grafana.com/access/canWrite": "false",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "1",
"grafana.com/inUse/rules": "1",
"grafana.com/provenance": "none",
@@ -336,6 +342,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canWrite": "true",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "none",
@@ -375,6 +382,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canWrite": "true",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "none",
@@ -417,6 +425,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canWrite": "true",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "api",
@@ -489,6 +498,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canWrite": "true",
+ "grafana.com/canUse": "true",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "none",
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx
index e336d7852bf..826395ff0ad 100644
--- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx
@@ -1,4 +1,5 @@
import { UserEvent } from '@testing-library/user-event';
+import { HttpResponse, http } from 'msw';
import { ReactNode } from 'react';
import { GrafanaRuleFormStep, renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
@@ -169,6 +170,70 @@ describe('Can create a new grafana managed alert using simplified routing', () =
expect(screen.getByDisplayValue('lotsa-emails')).toBeInTheDocument();
});
+ it('does not show contact points with canUse=false (imported) in the dropdown', async () => {
+ // Override the receivers handler to include a contact point that cannot be used (e.g., imported)
+ server.use(
+ http.get('/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/:namespace/receivers', () => {
+ return HttpResponse.json({
+ kind: 'ReceiverList',
+ apiVersion: 'notifications.alerting.grafana.app/v0alpha1',
+ metadata: {},
+ items: [
+ {
+ metadata: {
+ uid: 'regular-receiver',
+ annotations: {
+ 'grafana.com/provenance': '',
+ 'grafana.com/canUse': 'true',
+ 'grafana.com/access/canAdmin': 'true',
+ 'grafana.com/access/canDelete': 'true',
+ 'grafana.com/access/canWrite': 'true',
+ },
+ },
+ spec: {
+ title: 'regular-receiver',
+ integrations: [{ type: 'email', settings: { addresses: 'test@example.com' } }],
+ },
+ },
+ {
+ metadata: {
+ uid: 'imported-receiver',
+ annotations: {
+ 'grafana.com/provenance': 'converted_prometheus',
+ 'grafana.com/canUse': 'false',
+ 'grafana.com/access/canAdmin': 'true',
+ 'grafana.com/access/canDelete': 'false',
+ 'grafana.com/access/canWrite': 'false',
+ },
+ },
+ spec: {
+ title: 'imported-receiver',
+ integrations: [{ type: 'email', settings: { addresses: 'imported@example.com' } }],
+ },
+ },
+ ],
+ });
+ })
+ );
+
+ const { user } = renderRuleEditor();
+
+ await user.click(await ui.inputs.simplifiedRouting.contactPointRouting.find());
+
+ // Open the contact point dropdown
+ const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find();
+ const combobox = await within(contactPointInput).findByRole('combobox');
+ await user.click(combobox);
+
+ // Wait for options to load and verify contact point with canUse=false is not in the list
+ await waitFor(() => {
+ expect(screen.queryByRole('option', { name: /imported-receiver/i })).not.toBeInTheDocument();
+ });
+
+ // Verify that contact points with canUse=true are shown
+ expect(await screen.findByRole('option', { name: /regular-receiver/i })).toBeInTheDocument();
+ });
+
describe('switch modes enabled', () => {
testWithFeatureToggles({ enable: ['alertingQueryAndExpressionsStepMode', 'alertingNotificationsStepMode'] });
diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
index 177b5c23499..e4ee26f8637 100644
--- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
+++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
@@ -25,12 +25,15 @@ const getReceiversList = () => {
contactPoint.grafana_managed_receiver_configs?.find((integration) => {
return integration.provenance;
})?.provenance || KnownProvenance.None;
+ // Only receivers from Grafana configuration can be used (not imported ones)
+ const canUse = provenance !== KnownProvenance.ConvertedPrometheus;
return {
metadata: {
// This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses
uid: contactPoint.name,
annotations: {
[K8sAnnotations.Provenance]: provenance,
+ [K8sAnnotations.CanUse]: canUse ? 'true' : 'false',
[K8sAnnotations.AccessAdmin]: 'true',
[K8sAnnotations.AccessDelete]: cannotBeDeleted.includes(contactPoint.name) ? 'false' : 'true',
[K8sAnnotations.AccessWrite]: cannotBeEdited.includes(contactPoint.name) ? 'false' : 'true',
diff --git a/public/app/features/alerting/unified/utils/k8s/constants.ts b/public/app/features/alerting/unified/utils/k8s/constants.ts
index 4fdc2628e10..4bd23b58b73 100644
--- a/public/app/features/alerting/unified/utils/k8s/constants.ts
+++ b/public/app/features/alerting/unified/utils/k8s/constants.ts
@@ -20,6 +20,9 @@ export enum K8sAnnotations {
AccessDelete = 'grafana.com/access/canDelete',
/** Annotation key that indicates that the calling user is able to modify protected fields of this entity */
AccessModifyProtected = 'grafana.com/access/canModifyProtected',
+
+ /** Annotation key that indicates whether this entity can be used in routes and rules */
+ CanUse = 'grafana.com/canUse',
}
/**