mirror of
https://github.com/grafana/grafana.git
synced 2026-02-03 20:49:50 -05:00
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:
parent
130010607a
commit
12aac6fcfd
9 changed files with 247 additions and 12 deletions
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue