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 = {