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', } /**