Alerting: Adds contact point sorting and searching (#77390)

This commit is contained in:
Gilles De Mey 2023-11-07 11:05:12 +01:00 committed by GitHub
parent 60546a9f5a
commit be436ec6f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 233 additions and 80 deletions

View File

@ -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(
<AlertmanagerProvider accessType={'notification'}>
<ContactPoints />
</AlertmanagerProvider>,
{ 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(
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={MIMIR_DATASOURCE_UID}>
<ContactPoints />
</AlertmanagerProvider>,
{ wrapper: TestProvider }
<TestProvider>
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={MIMIR_DATASOURCE_UID}>
<ContactPoints />
</AlertmanagerProvider>
</TestProvider>
);
await waitFor(async () => {

View File

@ -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 */}
<Stack direction="row" alignItems="center">
<Text variant="body" color="secondary">
Define where notifications are sent, a contact point can contain multiple integrations.
</Text>
<Stack direction="row" alignItems="end">
<ContactPointsFilter />
<Spacer />
<Stack direction="row" gap={1}>
{addContactPointSupported && (
@ -152,6 +156,7 @@ const ContactPoints = () => {
</Stack>
<ContactPointsList
contactPoints={contactPoints}
search={search}
pageSize={DEFAULT_PAGE_SIZE}
onDelete={(name) => 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 (
<ContactPoint
key={`${contactPoint.name}-${index}`}
key={key}
name={contactPoint.name}
disabled={disabled}
onDelete={onDelete}
@ -225,6 +234,42 @@ const ContactPointsList = ({
);
};
const fuzzyFinder = new uFuzzy({
intraMode: 1,
intraIns: 1,
intraSub: 1,
intraDel: 1,
intraTrn: 1,
});
// let's search in two different haystacks, the name of the contact point and the type of the receiver(s)
function useContactPointsSearch(
contactPoints: ContactPointWithMetadata[],
search?: string
): ContactPointWithMetadata[] {
const nameHaystack = useMemo(() => {
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}`,

View File

@ -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 (
<Stack direction="row" alignItems="end" gap={0.5}>
<Field className={styles.noBottom} label="Search by name or type">
<Input
aria-label="search contact points"
placeholder="Search"
width={46}
prefix={<Icon name="search" />}
onChange={(event) => {
setSearchValue(event.currentTarget.value);
}}
value={searchValue}
/>
</Field>
<Button variant="secondary" icon="times" onClick={() => clear()} disabled={!hasInput} aria-label="clear">
Clear
</Button>
</Stack>
);
};
const getStyles = () => ({
noBottom: css({
marginBottom: 0,
}),
});
export { ContactPointsFilter };

View File

@ -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) =>

View File

@ -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<AlertManagerCortexConfig>(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;
};

View File

@ -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": [
{

View File

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

View File

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

View File

@ -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!] },

View File

@ -427,6 +427,10 @@ export function setupMswServer() {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});

View File

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