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
This commit is contained in:
Sonia Aguilar 2026-01-21 16:08:09 +01:00 committed by GitHub
parent 130010607a
commit 12aac6fcfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 247 additions and 12 deletions

View file

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

View file

@ -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(<ContactPointSelector onChange={onChangeHandler} />);
// render the contact point selector with includeUnusable=true to show all
const { user } = render(<ContactPointSelector onChange={onChangeHandler} includeUnusable />);
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(<ContactPointSelector onChange={onChangeHandler} />);
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(<ContactPointSelector onChange={onChangeHandler} includeUnusable />);
await user.click(screen.getByRole('combobox'));
// All contact points should be shown
expect(await screen.findAllByRole('option')).toHaveLength(contactPointsListWithUnusableItems.items.length);
});
});

View file

@ -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<ContactPoint>;
export type ContactPointSelectorProps = CustomComboBoxProps<ContactPoint> & {
/**
* 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<ComboboxOption>((item) => item.option);
const handleChange = (selectedOption: ComboboxOption<string> | 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 <Combobox {...props} loading={isLoading} options={options} onChange={handleChange} />;
return <Combobox {...comboboxProps} loading={isLoading} options={options} onChange={handleChange} />;
}
export { ContactPointSelector };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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