diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx
index 87ba1ca634c..28420a80172 100644
--- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx
@@ -7,6 +7,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { selectors } from '@grafana/e2e-selectors';
import { AccessControlAction } from 'app/types';
+import { setupMswServer } from '../../mockApi';
import { grantUserPermissions, mockDataSource } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { setupDataSources } from '../../testSetup/datasources';
@@ -29,10 +30,16 @@ import setupMimirFlavoredServer, { MIMIR_DATASOURCE_UID } from './__mocks__/mimi
*
* 3. Write tests for the hooks we call in the "container" components
* if those have any logic or data structure transformations in them.
+ *
+ * ⚠️ Always set up the MSW server only once – MWS does not support multiple calls to setupServer(); and causes all sorts of weird issues
*/
-describe('ContactPoints', () => {
- describe('Grafana managed alertmanager', () => {
- setupGrafanaManagedServer();
+const server = setupMswServer();
+
+describe('contact points', () => {
+ describe('Contact points with Grafana managed alertmanager', () => {
+ beforeEach(() => {
+ setupGrafanaManagedServer(server);
+ });
beforeAll(() => {
grantUserPermissions([
@@ -124,10 +131,35 @@ describe('ContactPoints', () => {
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
expect(deleteButton).toBeDisabled();
});
+
+ it('should be able to search', async () => {
+ render(
+
+
+ ,
+ { wrapper: TestProvider }
+ );
+
+ const searchInput = screen.getByRole('textbox', { name: 'search contact points' });
+ await userEvent.type(searchInput, 'slack');
+ expect(searchInput).toHaveValue('slack');
+
+ await waitFor(() => {
+ expect(screen.getByText('Slack with multiple channels')).toBeInTheDocument();
+ expect(screen.getAllByTestId('contact-point')).toHaveLength(1);
+ });
+
+ // ⚠️ for some reason, the query params are preserved for all tests so don't forget to clear the input
+ const clearButton = screen.getByRole('button', { name: 'clear' });
+ await userEvent.click(clearButton);
+ expect(searchInput).toHaveValue('');
+ });
});
- describe('Mimir-flavored alertmanager', () => {
- setupMimirFlavoredServer();
+ describe('Contact points with Mimir-flavored alertmanager', () => {
+ beforeEach(() => {
+ setupMimirFlavoredServer(server);
+ });
beforeAll(() => {
grantUserPermissions([
@@ -145,10 +177,11 @@ describe('ContactPoints', () => {
it('should show / hide loading states', async () => {
render(
-
-
- ,
- { wrapper: TestProvider }
+
+
+
+
+
);
await waitFor(async () => {
diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx
index 97a6c7cafc2..fc92842834d 100644
--- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx
@@ -1,6 +1,7 @@
import { css } from '@emotion/css';
+import uFuzzy from '@leeoniya/ufuzzy';
import { SerializedError } from '@reduxjs/toolkit';
-import { groupBy, size, upperFirst } from 'lodash';
+import { groupBy, size, uniq, upperFirst } from 'lodash';
import pluralize from 'pluralize';
import React, { Fragment, ReactNode, useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
@@ -8,13 +9,6 @@ import { useToggle } from 'react-use';
import { dateTime, GrafanaTheme2 } from '@grafana/data';
import {
- Alert,
- Dropdown,
- Icon,
- LoadingPlaceholder,
- Menu,
- Tooltip,
- useStyles2,
Text,
LinkButton,
TabsBar,
@@ -23,6 +17,13 @@ import {
Pagination,
Button,
Stack,
+ Alert,
+ LoadingPlaceholder,
+ useStyles2,
+ Menu,
+ Dropdown,
+ Tooltip,
+ Icon,
} from '@grafana/ui';
import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
@@ -31,6 +32,7 @@ import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { usePagination } from '../../hooks/usePagination';
+import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { INTEGRATION_ICONS } from '../../types/contact-points';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
@@ -48,6 +50,7 @@ import { UnusedContactPointBadge } from '../receivers/ReceiversTable';
import { ReceiverMetadataBadge } from '../receivers/grafanaAppReceivers/ReceiverMetadataBadge';
import { ReceiverPluginMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata';
+import { ContactPointsFilter } from './ContactPointsFilter';
import { useDeleteContactPointModal } from './Modals';
import { NotificationTemplates } from './NotificationTemplates';
import {
@@ -82,6 +85,9 @@ const ContactPoints = () => {
const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading);
const [ExportDrawer, showExportDrawer] = useExportContactPoint();
+ const [searchParams] = useURLSearchParams();
+ const { search } = getContactPointsFilters(searchParams);
+
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
@@ -122,10 +128,8 @@ const ContactPoints = () => {
) : (
<>
{/* TODO we can add some additional info here with a ToggleTip */}
-
-
- Define where notifications are sent, a contact point can contain multiple integrations.
-
+
+
{addContactPointSupported && (
@@ -152,6 +156,7 @@ const ContactPoints = () => {
showDeleteModal(name)}
disabled={updateAlertmanagerState.isLoading}
@@ -189,6 +194,7 @@ const ContactPoints = () => {
interface ContactPointsListProps {
contactPoints: ContactPointWithMetadata[];
+ search?: string;
disabled?: boolean;
onDelete: (name: string) => void;
pageSize?: number;
@@ -197,20 +203,23 @@ interface ContactPointsListProps {
const ContactPointsList = ({
contactPoints,
disabled = false,
+ search,
pageSize = DEFAULT_PAGE_SIZE,
onDelete,
}: ContactPointsListProps) => {
- const { page, pageItems, numberOfPages, onPageChange } = usePagination(contactPoints, 1, pageSize);
+ const searchResults = useContactPointsSearch(contactPoints, search);
+ const { page, pageItems, numberOfPages, onPageChange } = usePagination(searchResults, 1, pageSize);
return (
<>
{pageItems.map((contactPoint, index) => {
const provisioned = isProvisioned(contactPoint);
const policies = contactPoint.numberOfPolicies;
+ const key = `${contactPoint.name}-${index}`;
return (
{
+ return contactPoints.map((contactPoint) => contactPoint.name);
+ }, [contactPoints]);
+
+ const typeHaystack = useMemo(() => {
+ return contactPoints.map((contactPoint) =>
+ // we're using the resolved metadata key here instead of the "type" property – ex. we alias "teams" to "microsoft teams"
+ contactPoint.grafana_managed_receiver_configs.map((receiver) => receiver[RECEIVER_META_KEY].name).join(' ')
+ );
+ }, [contactPoints]);
+
+ if (!search) {
+ return contactPoints;
+ }
+
+ const nameHits = fuzzyFinder.filter(nameHaystack, search) ?? [];
+ const typeHits = fuzzyFinder.filter(typeHaystack, search) ?? [];
+
+ const hits = [...nameHits, ...typeHits];
+
+ return uniq(hits).map((id) => contactPoints[id]) ?? [];
+}
+
interface ContactPointProps {
name: string;
disabled?: boolean;
@@ -571,6 +616,10 @@ const useExportContactPoint = (): ExportProps => {
return [drawer, handleOpen];
};
+const getContactPointsFilters = (searchParams: URLSearchParams) => ({
+ search: searchParams.get('search') ?? undefined,
+});
+
const getStyles = (theme: GrafanaTheme2) => ({
contactPointWrapper: css({
borderRadius: `${theme.shape.radius.default}`,
diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointsFilter.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointsFilter.tsx
new file mode 100644
index 00000000000..c21ceed72b8
--- /dev/null
+++ b/public/app/features/alerting/unified/components/contact-points/ContactPointsFilter.tsx
@@ -0,0 +1,61 @@
+import { css } from '@emotion/css';
+import React, { useCallback, useState } from 'react';
+import { useDebounce } from 'react-use';
+
+import { Stack } from '@grafana/experimental';
+import { Button, Field, Icon, Input, useStyles2 } from '@grafana/ui';
+
+import { useURLSearchParams } from '../../hooks/useURLSearchParams';
+
+const ContactPointsFilter = () => {
+ const styles = useStyles2(getStyles);
+
+ const [searchParams, setSearchParams] = useURLSearchParams();
+
+ const defaultValue = searchParams.get('search') ?? '';
+ const [searchValue, setSearchValue] = useState(defaultValue);
+
+ const [_, cancel] = useDebounce(
+ () => {
+ setSearchParams({ search: searchValue }, true);
+ },
+ 300,
+ [setSearchParams, searchValue]
+ );
+
+ const clear = useCallback(() => {
+ cancel();
+ setSearchValue('');
+ setSearchParams({ search: '' }, true);
+ }, [cancel, setSearchParams]);
+
+ const hasInput = Boolean(defaultValue);
+
+ return (
+
+
+ }
+ onChange={(event) => {
+ setSearchValue(event.currentTarget.value);
+ }}
+ value={searchValue}
+ />
+
+
+
+ );
+};
+
+const getStyles = () => ({
+ noBottom: css({
+ marginBottom: 0,
+ }),
+});
+
+export { ContactPointsFilter };
diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts
index 72116c2598f..e92e20166b8 100644
--- a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts
+++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts
@@ -1,18 +1,17 @@
import { rest } from 'msw';
+import { SetupServer } from 'msw/lib/node';
import { AlertmanagerChoice, AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { ReceiversStateDTO } from 'app/types';
-import { mockApi, setupMswServer } from '../../../mockApi';
+import { mockApi } from '../../../mockApi';
import { mockAlertmanagerChoiceResponse } from '../../../mocks/alertmanagerApi';
import { grafanaNotifiersMock } from '../../../mocks/grafana-notifiers';
import alertmanagerMock from './alertmanager.config.mock.json';
import receiversMock from './receivers.mock.json';
-export default () => {
- const server = setupMswServer();
-
+export default (server: SetupServer) => {
server.use(
// this endpoint is a grafana built-in alertmanager
rest.get('/api/alertmanager/grafana/config/api/v1/alerts', (_req, res, ctx) =>
diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts
index f27d795cf6c..88ef97018e9 100644
--- a/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts
+++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts
@@ -1,17 +1,14 @@
import { rest } from 'msw';
+import { SetupServer } from 'msw/lib/node';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
-import { setupMswServer } from '../../../mockApi';
-
import mimirAlertmanagerMock from './alertmanager.mimir.config.mock.json';
// this one emulates a mimir server setup
export const MIMIR_DATASOURCE_UID = 'mimir';
-export default () => {
- const server = setupMswServer();
-
+export default (server: SetupServer) => {
server.use(
rest.get(`/api/alertmanager/${MIMIR_DATASOURCE_UID}/config/api/v1/alerts`, (_req, res, ctx) =>
res(ctx.json(mimirAlertmanagerMock))
@@ -22,4 +19,6 @@ export default () => {
// this endpoint will respond if the OnCall plugin is installed
rest.get('/api/plugins/grafana-oncall-app/settings', (_req, res, ctx) => res(ctx.status(404)))
);
+
+ return server;
};
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 5d26e30334e..ca6b3c77572 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
@@ -32,6 +32,34 @@ exports[`useContactPoints should return contact points with status 1`] = `
"name": "grafana-default-email",
"numberOfPolicies": 0,
},
+ {
+ "grafana_managed_receiver_configs": [
+ {
+ "disableResolveMessage": false,
+ "name": "lotsa-emails",
+ "secureFields": {},
+ "settings": {
+ "addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
+ "singleEmail": false,
+ },
+ "type": "email",
+ "uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",
+ Symbol(receiver_status): {
+ "lastNotifyAttempt": "",
+ "lastNotifyAttemptDuration": "",
+ "name": "email",
+ "sendResolved": true,
+ },
+ Symbol(receiver_metadata): {
+ "description": "Sends notifications using Grafana server configured SMTP settings",
+ "name": "Email",
+ },
+ Symbol(receiver_plugin_metadata): undefined,
+ },
+ ],
+ "name": "lotsa-emails",
+ "numberOfPolicies": 0,
+ },
{
"grafana_managed_receiver_configs": [
{
@@ -61,34 +89,6 @@ exports[`useContactPoints should return contact points with status 1`] = `
"name": "provisioned-contact-point",
"numberOfPolicies": 0,
},
- {
- "grafana_managed_receiver_configs": [
- {
- "disableResolveMessage": false,
- "name": "lotsa-emails",
- "secureFields": {},
- "settings": {
- "addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
- "singleEmail": false,
- },
- "type": "email",
- "uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",
- Symbol(receiver_status): {
- "lastNotifyAttempt": "",
- "lastNotifyAttemptDuration": "",
- "name": "email",
- "sendResolved": true,
- },
- Symbol(receiver_metadata): {
- "description": "Sends notifications using Grafana server configured SMTP settings",
- "name": "Email",
- },
- Symbol(receiver_plugin_metadata): undefined,
- },
- ],
- "name": "lotsa-emails",
- "numberOfPolicies": 0,
- },
{
"grafana_managed_receiver_configs": [
{
diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx
index b5c12d7988f..50f4fdbd03d 100644
--- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx
@@ -4,14 +4,19 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { AccessControlAction } from 'app/types';
+import { setupMswServer } from '../../mockApi';
import { grantUserPermissions } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer';
import { useContactPointsWithStatus } from './useContactPoints';
+const server = setupMswServer();
+
describe('useContactPoints', () => {
- setupGrafanaManagedServer();
+ beforeEach(() => {
+ setupGrafanaManagedServer(server);
+ });
beforeAll(() => {
grantUserPermissions([AccessControlAction.AlertingNotificationsRead]);
diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx
index 4b70e5d6e2f..90557614f0e 100644
--- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx
@@ -91,7 +91,7 @@ export function useContactPointsWithStatus() {
onCallPluginStatusLoading ||
onCallPluginIntegrationsLoading;
- const contactPoints = fetchAlertmanagerConfiguration.contactPoints;
+ const contactPoints = fetchAlertmanagerConfiguration.contactPoints.sort((a, b) => a.name.localeCompare(b.name));
return {
error,
diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx
index dce2c07b1e2..1686ce6404a 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx
@@ -87,7 +87,15 @@ const rulerRuleIdentifier = ruleId.fromRulerRule('prometheus', 'ns-default', 'gr
beforeAll(() => {
setBackendSrv(backendSrv);
+ const promDsSettings = mockDataSource({
+ name: dsName,
+ uid: dsName,
+ });
+ setupDataSources(promDsSettings);
+});
+
+beforeEach(() => {
// some action buttons need to check what Alertmanager setup we have for Grafana managed rules
mockAlertmanagerChoiceResponse(server, {
alertmanagersChoice: AlertmanagerChoice.Internal,
@@ -96,13 +104,6 @@ beforeAll(() => {
// we need to mock this one for the "declare incident" button
mockPluginSettings(server, SupportedPlugin.Incident);
- const promDsSettings = mockDataSource({
- name: dsName,
- uid: dsName,
- });
-
- setupDataSources(promDsSettings);
-
mockAlertRuleApi(server).rulerRules('grafana', {
[mockGrafanaRule.namespace.name]: [
{ name: mockGrafanaRule.group.name, interval: '1m', rules: [mockGrafanaRule.rulerRule!] },
diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts
index 63cca9d20a9..3fafab6ee73 100644
--- a/public/app/features/alerting/unified/mockApi.ts
+++ b/public/app/features/alerting/unified/mockApi.ts
@@ -427,6 +427,10 @@ export function setupMswServer() {
server.listen({ onUnhandledRequest: 'error' });
});
+ afterEach(() => {
+ server.resetHandlers();
+ });
+
afterAll(() => {
server.close();
});
diff --git a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx
index e98637b8e7d..d5ed7ececcc 100644
--- a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx
+++ b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx
@@ -65,17 +65,19 @@ const fakeResponse: PromRulesResponse = {
const server = setupMswServer();
-mockPromRulesApiResponse(server, fakeResponse);
-const originRule: RulerGrafanaRuleDTO = mockRulerGrafanaRule(
- {
- for: '1m',
- labels: { severity: 'critical', region: 'nasa' },
- annotations: { [Annotation.summary]: 'This is a very important alert rule' },
- },
- { uid: 'grafana-rule-1', title: 'First Grafana Rule', data: [] }
-);
-mockRulerRulesApiResponse(server, 'grafana', {
- 'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
+beforeEach(() => {
+ mockPromRulesApiResponse(server, fakeResponse);
+ const originRule: RulerGrafanaRuleDTO = mockRulerGrafanaRule(
+ {
+ for: '1m',
+ labels: { severity: 'critical', region: 'nasa' },
+ annotations: { [Annotation.summary]: 'This is a very important alert rule' },
+ },
+ { uid: 'grafana-rule-1', title: 'First Grafana Rule', data: [] }
+ );
+ mockRulerRulesApiResponse(server, 'grafana', {
+ 'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
+ });
});
const defaultOptions: UnifiedAlertListOptions = {