Alerting: Consolidate contact points dropdown and add filter in alert rules (#91690)

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
This commit is contained in:
Tom Ratcliffe 2024-08-13 12:56:13 +01:00 committed by GitHub
parent 149f02aebe
commit 735954386f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 378 additions and 420 deletions

View File

@ -1769,9 +1769,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]

View File

@ -4,6 +4,7 @@ import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import {
AlertManagerCortexConfig,
AlertManagerDataSourceJsonData,
@ -19,7 +20,6 @@ import NotificationPolicies, { findRoutesMatchingFilters } from './NotificationP
import { fetchAlertManagerConfig, fetchStatus, updateAlertManagerConfig } from './api/alertmanager';
import { alertmanagerApi } from './api/alertmanagerApi';
import { discoverAlertmanagerFeatures } from './api/buildInfo';
import * as grafanaApp from './components/receivers/grafanaAppReceivers/grafanaApp';
import { MockDataSourceSrv, mockDataSource, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks';
import { defaultGroupBy } from './utils/amroutes';
import { getAllDataSources } from './utils/config';
@ -45,7 +45,8 @@ const mocks = {
},
contextSrv: jest.mocked(contextSrv),
};
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
setupMswServer();
const renderNotificationPolicies = (alertManagerSourceName?: string) => {
return render(<NotificationPolicies />, {
@ -195,7 +196,6 @@ describe('NotificationPolicies', () => {
mocks.contextSrv.evaluatePermission.mockImplementation(() => []);
mocks.api.discoverAlertmanagerFeatures.mockResolvedValue({ lazyConfigInit: false });
setDataSourceSrv(new MockDataSourceSrv(dataSources));
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
});
afterEach(() => {

View File

@ -193,10 +193,9 @@ const AmRoutes = () => {
}
// edit, add, delete modals
const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(receivers, handleAdd, updatingTree);
const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(handleAdd, updatingTree);
const [editModal, openEditModal, closeEditModal] = useEditPolicyModal(
selectedAlertmanager ?? '',
receivers,
handleSave,
updatingTree
);
@ -253,7 +252,6 @@ const AmRoutes = () => {
<Stack direction="column" gap={1}>
{rootRoute && (
<NotificationPoliciesFilter
receivers={receivers}
onChangeMatchers={setLabelMatchersFilter}
onChangeReceiver={setContactPointFilter}
matchingCount={routesMatchingFilters.matchedRoutesWithPath.size}

View File

@ -29,6 +29,7 @@ export const AlertGroupFilter = ({ groups }: Props) => {
groupBy: null,
queryString: null,
alertState: null,
contactPoint: null,
});
setTimeout(() => setFilterKey(filterKey + 1), 100);
};

View File

@ -8,7 +8,6 @@ import { Trans } from 'app/core/internationalization';
import { PrimaryText } from 'app/features/alerting/unified/components/common/TextVariants';
import { ContactPointHeader } from 'app/features/alerting/unified/components/contact-points/ContactPointHeader';
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting';
import { INTEGRATION_ICONS } from '../../types/contact-points';
@ -152,37 +151,56 @@ interface ContactPointReceiverMetadata {
}
type ContactPointReceiverSummaryProps = {
receivers: GrafanaManagedReceiverConfig[];
receivers: ReceiverConfigWithMetadata[];
limit?: number;
};
/**
* This summary is used when we're dealing with non-Grafana managed alertmanager since they
* don't have any metadata worth showing other than a summary of what types are configured for the contact point
*/
export const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => {
export const ContactPointReceiverSummary = ({ receivers, limit }: ContactPointReceiverSummaryProps) => {
// limit for how many integrations are rendered
const INTEGRATIONS_LIMIT = limit ?? Number.MAX_VALUE;
const countByType = groupBy(receivers, (receiver) => receiver.type);
const numberOfUniqueIntegrations = size(countByType);
const integrationsShown = Object.entries(countByType).slice(0, INTEGRATIONS_LIMIT);
const numberOfIntegrationsNotShown = numberOfUniqueIntegrations - INTEGRATIONS_LIMIT;
return (
<Stack direction="column" gap={0}>
<Stack direction="row" alignItems="center" gap={1}>
{Object.entries(countByType).map(([type, receivers], index) => {
{integrationsShown.map(([type, receivers], index) => {
const iconName = INTEGRATION_ICONS[type];
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
const isLastItem = size(countByType) - 1 === index;
// Pick the first integration of the grouped receivers, since they should all be the same type
// e.g. if we have multiple Oncall, they _should_ all have the same plugin metadata,
// so we can just use the first one for additional display purposes
const receiver = receivers[0];
return (
<Fragment key={type}>
<Stack direction="row" alignItems="center" gap={0.5}>
{receiver[RECEIVER_PLUGIN_META_KEY]?.icon && (
<img
width="14px"
src={receiver[RECEIVER_PLUGIN_META_KEY]?.icon}
alt={receiver[RECEIVER_PLUGIN_META_KEY]?.title}
/>
)}
{iconName && <Icon name={iconName} />}
<Text variant="body">
<span>
{receiverName}
{receivers.length > 1 && receivers.length}
</Text>
</span>
</Stack>
{!isLastItem && '⋅'}
</Fragment>
);
})}
{numberOfIntegrationsNotShown > 0 && <span>{`+${numberOfIntegrationsNotShown} more`}</span>}
</Stack>
</Stack>
);

View File

@ -1,52 +1,115 @@
import { SelectableValue } from '@grafana/data';
import { Select, SelectCommonProps, Text, Stack } from '@grafana/ui';
import { css, cx, keyframes } from '@emotion/css';
import { useMemo, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Select, SelectCommonProps, Stack, Alert, IconButton, Text, useStyles2 } from '@grafana/ui';
import { ContactPointReceiverSummary } from 'app/features/alerting/unified/components/contact-points/ContactPoint';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from '../contact-points/constants';
import { useContactPointsWithStatus } from '../contact-points/useContactPoints';
import { ReceiverConfigWithMetadata } from '../contact-points/utils';
import { ContactPointWithMetadata } from '../contact-points/utils';
export const ContactPointSelector = (props: SelectCommonProps<string>) => {
const MAX_CONTACT_POINTS_RENDERED = 500;
// Mock sleep method, as fetching receivers is very fast and may seem like it hasn't occurred
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const LOADING_SPINNER_DURATION = 1000;
type ContactPointSelectorProps = {
selectProps: SelectCommonProps<ContactPointWithMetadata>;
showRefreshButton?: boolean;
/** Name of a contact point to optionally find and set as the preset value on the dropdown */
selectedContactPointName?: string | null;
};
export const ContactPointSelector = ({
selectProps,
showRefreshButton,
selectedContactPointName,
}: ContactPointSelectorProps) => {
const { selectedAlertmanager } = useAlertmanager();
const { contactPoints, isLoading, error } = useContactPointsWithStatus({ alertmanager: selectedAlertmanager! });
const { contactPoints, isLoading, error, refetch } = useContactPointsWithStatus({
alertmanager: selectedAlertmanager!,
});
const [loaderSpinning, setLoaderSpinning] = useState(false);
const styles = useStyles2(getStyles);
// TODO error handling
if (error) {
return <span>Failed to load contact points</span>;
}
const options: Array<SelectableValue<string>> = contactPoints.map((contactPoint) => {
const options: Array<SelectableValue<ContactPointWithMetadata>> = contactPoints.map((contactPoint) => {
return {
label: contactPoint.name,
value: contactPoint.name,
component: () => <ReceiversSummary receivers={contactPoint.grafana_managed_receiver_configs} />,
value: contactPoint,
component: () => (
<Text variant="bodySmall" color="secondary">
<ContactPointReceiverSummary receivers={contactPoint.grafana_managed_receiver_configs} limit={2} />
</Text>
),
};
});
return <Select options={options} isLoading={isLoading} {...props} />;
};
const matchedContactPoint: SelectableValue<ContactPointWithMetadata> | null = useMemo(() => {
return options.find((option) => option.value?.name === selectedContactPointName) || null;
}, [options, selectedContactPointName]);
interface ReceiversProps {
receivers: ReceiverConfigWithMetadata[];
}
// force some minimum wait period for fetching contact points
const onClickRefresh = () => {
setLoaderSpinning(true);
Promise.all([refetch(), sleep(LOADING_SPINNER_DURATION)]).finally(() => {
setLoaderSpinning(false);
});
};
// TODO error handling
if (error) {
return <Alert title="Failed to fetch contact points" severity="error" />;
}
const ReceiversSummary = ({ receivers }: ReceiversProps) => {
return (
<Stack direction="row">
{receivers.map((receiver, index) => (
<Stack key={receiver.uid ?? index} direction="row" gap={0.5}>
{receiver[RECEIVER_PLUGIN_META_KEY]?.icon && (
<img
width="16px"
src={receiver[RECEIVER_PLUGIN_META_KEY]?.icon}
alt={receiver[RECEIVER_PLUGIN_META_KEY]?.title}
/>
)}
<Text key={index} variant="bodySmall" color="secondary">
{receiver[RECEIVER_META_KEY].name ?? receiver[RECEIVER_PLUGIN_META_KEY]?.title ?? receiver.type}
</Text>
</Stack>
))}
<Stack>
<Select
virtualized={options.length > MAX_CONTACT_POINTS_RENDERED}
options={options}
value={matchedContactPoint}
{...selectProps}
isLoading={isLoading}
/>
{showRefreshButton && (
<IconButton
name="sync"
onClick={onClickRefresh}
aria-label="Refresh contact points"
tooltip="Refresh contact points list"
className={cx(styles.refreshButton, {
[styles.loading]: loaderSpinning || isLoading,
})}
/>
)}
</Stack>
);
};
const rotation = keyframes({
from: {
transform: 'rotate(0deg)',
},
to: {
transform: 'rotate(720deg)',
},
});
const getStyles = (theme: GrafanaTheme2) => ({
refreshButton: css({
color: theme.colors.text.secondary,
cursor: 'pointer',
borderRadius: theme.shape.radius.circle,
overflow: 'hidden',
}),
loading: css({
pointerEvents: 'none',
[theme.transitions.handleMotion('no-preference')]: {
animation: `${rotation} 2s infinite linear`,
},
[theme.transitions.handleMotion('reduce')]: {
animation: `${rotation} 6s infinite linear`,
},
}),
});

View File

@ -3,12 +3,14 @@ import { render } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { Button } from '@grafana/ui';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { AccessControlAction } from 'app/types';
import { RouteWithID } from '../../../../../plugins/datasource/alertmanager/types';
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
import { FormAmRoute } from '../../types/amroutes';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
import { AmRootRouteForm } from './EditDefaultPolicyForm';
@ -20,12 +22,15 @@ const ui = {
groupIntervalInput: byRole('textbox', { name: /Group interval/ }),
repeatIntervalInput: byRole('textbox', { name: /Repeat interval/ }),
};
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
setupMswServer();
// TODO Default and Notification policy form should be unified so we don't need to maintain two almost identical forms
describe('EditDefaultPolicyForm', function () {
beforeEach(() => {
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
]);
});
describe('Timing options', function () {
it('should render prometheus duration strings in form inputs', async function () {
const { user } = renderRouteForm({
@ -47,7 +52,6 @@ describe('EditDefaultPolicyForm', function () {
id: '0',
receiver: 'default',
},
[{ value: 'default', label: 'Default' }],
onSubmit
);
@ -78,7 +82,6 @@ describe('EditDefaultPolicyForm', function () {
id: '0',
receiver: 'default',
},
[{ value: 'default', label: 'Default' }],
onSubmit
);
@ -105,7 +108,6 @@ describe('EditDefaultPolicyForm', function () {
group_interval: '2d4h30m35s',
repeat_interval: '1w2d6h',
},
[{ value: 'default', label: 'Default' }],
onSubmit
);
@ -128,18 +130,15 @@ describe('EditDefaultPolicyForm', function () {
});
});
function renderRouteForm(
route: RouteWithID,
receivers: AmRouteReceiver[] = [],
onSubmit: (route: Partial<FormAmRoute>) => void = noop
) {
function renderRouteForm(route: RouteWithID, onSubmit: (route: Partial<FormAmRoute>) => void = noop) {
return render(
<AmRootRouteForm
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
actionButtons={<Button type="submit">Update default policy</Button>}
onSubmit={onSubmit}
receivers={receivers}
route={route}
/>
<AlertmanagerProvider accessType="instance">
<AmRootRouteForm
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
actionButtons={<Button type="submit">Update default policy</Button>}
onSubmit={onSubmit}
route={route}
/>
</AlertmanagerProvider>
);
}

View File

@ -1,7 +1,9 @@
import { ReactNode, useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Collapse, Field, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui';
import { Collapse, Field, Link, MultiSelect, useStyles2 } from '@grafana/ui';
import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils';
import { RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../../types/amroutes';
@ -9,14 +11,12 @@ import {
amRouteToFormAmRoute,
commonGroupByOptions,
mapMultiSelectValueToStrings,
mapSelectValueToString,
promDurationValidator,
repeatIntervalValidator,
stringsToSelectableValues,
stringToSelectableValue,
} from '../../utils/amroutes';
import { makeAMLink } from '../../utils/misc';
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
import { PromDurationInput } from './PromDurationInput';
import { getFormStyles } from './formStyles';
@ -26,17 +26,10 @@ export interface AmRootRouteFormProps {
alertManagerSourceName: string;
actionButtons: ReactNode;
onSubmit: (route: Partial<FormAmRoute>) => void;
receivers: AmRouteReceiver[];
route: RouteWithID;
}
export const AmRootRouteForm = ({
actionButtons,
alertManagerSourceName,
onSubmit,
receivers,
route,
}: AmRootRouteFormProps) => {
export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmit, route }: AmRootRouteFormProps) => {
const styles = useStyles2(getFormStyles);
const [isTimingOptionsExpanded, setIsTimingOptionsExpanded] = useState(false);
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route.group_by));
@ -62,13 +55,13 @@ export const AmRootRouteForm = ({
<>
<div className={styles.container} data-testid="am-receiver-select">
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<Select
aria-label="Default contact point"
{...field}
className={styles.input}
onChange={(value) => onChange(mapSelectValueToString(value))}
options={receivers}
render={({ field: { onChange, ref, value, ...field } }) => (
<ContactPointSelector
selectProps={{
...field,
onChange: (changeValue) => handleContactPointSelect(changeValue, onChange),
}}
selectedContactPointName={value}
/>
)}
control={control}

View File

@ -3,12 +3,14 @@ import { render } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { Button } from '@grafana/ui';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { AccessControlAction } from 'app/types';
import { RouteWithID } from '../../../../../plugins/datasource/alertmanager/types';
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { FormAmRoute } from '../../types/amroutes';
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
import { AmRoutesExpandedForm } from './EditNotificationPolicyForm';
@ -21,11 +23,16 @@ const ui = {
repeatIntervalInput: byRole('textbox', { name: /Repeat interval/ }),
};
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
setupMswServer();
// TODO Default and Notification policy form should be unified so we don't need to maintain two almost identical forms
describe('EditNotificationPolicyForm', function () {
beforeEach(() => {
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
]);
});
describe('Timing options', function () {
it('should render prometheus duration strings in form inputs', async function () {
renderRouteForm({
@ -48,7 +55,6 @@ describe('EditNotificationPolicyForm', function () {
id: '1',
receiver: 'default',
},
[{ value: 'default', label: 'Default' }],
onSubmit
);
@ -79,7 +85,6 @@ describe('EditNotificationPolicyForm', function () {
id: '1',
receiver: 'default',
},
[{ value: 'default', label: 'Default' }],
onSubmit
);
@ -106,7 +111,6 @@ describe('EditNotificationPolicyForm', function () {
group_interval: '2d4h30m35s',
repeat_interval: '1w2d6h',
},
[{ value: 'default', label: 'Default' }],
onSubmit
);
@ -128,17 +132,12 @@ describe('EditNotificationPolicyForm', function () {
});
});
function renderRouteForm(
route: RouteWithID,
receivers: AmRouteReceiver[] = [],
onSubmit: (route: Partial<FormAmRoute>) => void = noop
) {
function renderRouteForm(route: RouteWithID, onSubmit: (route: Partial<FormAmRoute>) => void = noop) {
return render(
<AlertmanagerProvider accessType="instance">
<AlertmanagerProvider accessType="instance" alertmanagerSourceName={GRAFANA_RULES_SOURCE_NAME}>
<AmRoutesExpandedForm
actionButtons={<Button type="submit">Update default policy</Button>}
onSubmit={onSubmit}
receivers={receivers}
route={route}
/>
</AlertmanagerProvider>

View File

@ -16,52 +16,42 @@ import {
Switch,
useStyles2,
} from '@grafana/ui';
import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils';
import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { useMuteTimingOptions } from '../../hooks/useMuteTimingOptions';
import { FormAmRoute } from '../../types/amroutes';
import { SupportedPlugin } from '../../types/pluginBridges';
import { matcherFieldOptions } from '../../utils/alertmanager';
import {
amRouteToFormAmRoute,
commonGroupByOptions,
emptyArrayFieldMatcher,
mapMultiSelectValueToStrings,
mapSelectValueToString,
promDurationValidator,
repeatIntervalValidator,
stringToSelectableValue,
stringsToSelectableValues,
} from '../../utils/amroutes';
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
import { PromDurationInput } from './PromDurationInput';
import { getFormStyles } from './formStyles';
import { routeTimingsFields } from './routeTimingsFields';
export interface AmRoutesExpandedFormProps {
receivers: AmRouteReceiver[];
route?: RouteWithID;
onSubmit: (route: Partial<FormAmRoute>) => void;
actionButtons: ReactNode;
defaults?: Partial<FormAmRoute>;
}
export const AmRoutesExpandedForm = ({
actionButtons,
receivers,
route,
onSubmit,
defaults,
}: AmRoutesExpandedFormProps) => {
export const AmRoutesExpandedForm = ({ actionButtons, route, onSubmit, defaults }: AmRoutesExpandedFormProps) => {
const styles = useStyles2(getStyles);
const formStyles = useStyles2(getFormStyles);
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route?.group_by));
const muteTimingOptions = useMuteTimingOptions();
const emptyMatcher = [{ name: '', operator: MatcherOperator.equal, value: '' }];
const receiversWithOnCallOnTop = receivers.sort(onCallFirst);
const formAmRoute = {
...amRouteToFormAmRoute(route),
...defaults,
@ -168,14 +158,15 @@ export const AmRoutesExpandedForm = ({
<Field label="Contact point">
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<Select
aria-label="Contact point"
{...field}
className={formStyles.input}
onChange={(value) => onChange(mapSelectValueToString(value))}
options={receiversWithOnCallOnTop}
isClearable
render={({ field: { onChange, ref, value, ...field } }) => (
<ContactPointSelector
selectProps={{
...field,
className: formStyles.input,
onChange: (value) => handleContactPointSelect(value, onChange),
isClearable: true,
}}
selectedContactPointName={value}
/>
)}
control={control}
@ -298,14 +289,6 @@ export const AmRoutesExpandedForm = ({
);
};
function onCallFirst(receiver: AmRouteReceiver) {
if (receiver.grafanaAppReceiverType === SupportedPlugin.OnCall) {
return -1;
} else {
return 0;
}
}
const getStyles = (theme: GrafanaTheme2) => {
const commonSpacing = theme.spacing(3.5);

View File

@ -2,9 +2,9 @@ import { css } from '@emotion/css';
import { debounce, isEqual } from 'lodash';
import { useCallback, useEffect, useRef } from 'react';
import { SelectableValue } from '@grafana/data';
import { Button, Field, Icon, Input, Label, Select, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { Button, Field, Icon, Input, Label, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
import { ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { matcherToObjectMatcher } from '../../utils/alertmanager';
@ -15,14 +15,12 @@ import {
} from '../../utils/matchers';
interface NotificationPoliciesFilterProps {
receivers: Receiver[];
onChangeMatchers: (labels: ObjectMatcher[]) => void;
onChangeReceiver: (receiver: string | undefined) => void;
matchingCount: number;
}
const NotificationPoliciesFilter = ({
receivers,
onChangeReceiver,
onChangeMatchers,
matchingCount,
@ -47,12 +45,9 @@ const NotificationPoliciesFilter = ({
if (searchInputRef.current) {
searchInputRef.current.value = '';
}
setSearchParams({ contactPoint: undefined, queryString: undefined });
setSearchParams({ contactPoint: '', queryString: undefined });
}, [setSearchParams]);
const receiverOptions: Array<SelectableValue<string>> = receivers.map(toOption);
const selectedContactPoint = receiverOptions.find((option) => option.value === contactPoint) ?? null;
const hasFilters = queryString || contactPoint;
let inputValid = Boolean(queryString && queryString.length > 3);
@ -103,16 +98,17 @@ const NotificationPoliciesFilter = ({
/>
</Field>
<Field label="Search by contact point" style={{ marginBottom: 0 }}>
<Select
id="receiver"
aria-label="Search by contact point"
value={selectedContactPoint}
options={receiverOptions}
onChange={(option) => {
setSearchParams({ contactPoint: option?.value });
<ContactPointSelector
selectProps={{
id: 'receiver',
'aria-label': 'Search by contact point',
onChange: (option) => {
setSearchParams({ contactPoint: option?.value?.name });
},
width: 28,
isClearable: true,
}}
width={28}
isClearable
selectedContactPointName={searchParams.get('contactPoint') ?? undefined}
/>
</Field>
{hasFilters && (
@ -178,11 +174,6 @@ export function findRoutesByMatchers(route: RouteWithID, labelMatchersFilter: Ob
return labelMatchersFilter.every((filter) => routeMatchers.some((matcher) => isEqual(filter, matcher)));
}
const toOption = (receiver: Receiver) => ({
label: receiver.name,
value: receiver.name,
});
const getNotificationPoliciesFilters = (searchParams: URLSearchParams) => ({
queryString: searchParams.get('queryString') ?? undefined,
contactPoint: searchParams.get('contactPoint') ?? undefined,

View File

@ -2,19 +2,12 @@ import { groupBy } from 'lodash';
import { FC, useCallback, useMemo, useState } from 'react';
import { Button, Icon, Modal, ModalProps, Spinner, Stack } from '@grafana/ui';
import {
AlertmanagerGroup,
AlertState,
ObjectMatcher,
Receiver,
RouteWithID,
} from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerGroup, AlertState, ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../../types/amroutes';
import { MatcherFormatter } from '../../utils/matchers';
import { InsertPosition } from '../../utils/routeTree';
import { AlertGroup } from '../alert-groups/AlertGroup';
import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp';
import { AlertGroupsSummary } from './AlertGroupsSummary';
import { AmRootRouteForm } from './EditDefaultPolicyForm';
@ -26,14 +19,12 @@ type AddModalHook<T = undefined> = [JSX.Element, (item: T, position: InsertPosit
type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void];
const useAddPolicyModal = (
receivers: Receiver[] = [],
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void,
loading: boolean
): AddModalHook<RouteWithID> => {
const [showModal, setShowModal] = useState(false);
const [insertPosition, setInsertPosition] = useState<InsertPosition | undefined>(undefined);
const [referenceRoute, setReferenceRoute] = useState<RouteWithID>();
const AmRouteReceivers = useGetAmRouteReceiverWithGrafanaAppTypes(receivers);
const handleDismiss = useCallback(() => {
setReferenceRoute(undefined);
@ -60,7 +51,6 @@ const useAddPolicyModal = (
title="Add notification policy"
>
<AmRoutesExpandedForm
receivers={AmRouteReceivers}
defaults={{
groupBy: referenceRoute?.group_by,
}}
@ -80,7 +70,7 @@ const useAddPolicyModal = (
/>
</Modal>
),
[AmRouteReceivers, handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal]
[handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal]
);
return [modalElement, handleShow, handleDismiss];
@ -88,14 +78,12 @@ const useAddPolicyModal = (
const useEditPolicyModal = (
alertManagerSourceName: string,
receivers: Receiver[],
handleSave: (route: Partial<FormAmRoute>) => void,
loading: boolean
): EditModalHook => {
const [showModal, setShowModal] = useState(false);
const [isDefaultPolicy, setIsDefaultPolicy] = useState(false);
const [route, setRoute] = useState<RouteWithID>();
const AmRouteReceivers = useGetAmRouteReceiverWithGrafanaAppTypes(receivers);
const handleDismiss = useCallback(() => {
setRoute(undefined);
@ -126,7 +114,6 @@ const useEditPolicyModal = (
// passing it down all the way here is a code smell
alertManagerSourceName={alertManagerSourceName}
onSubmit={handleSave}
receivers={AmRouteReceivers}
route={route}
actionButtons={
<Modal.ButtonRow>
@ -140,7 +127,6 @@ const useEditPolicyModal = (
)}
{!isDefaultPolicy && (
<AmRoutesExpandedForm
receivers={AmRouteReceivers}
route={route}
onSubmit={handleSave}
actionButtons={
@ -155,7 +141,7 @@ const useEditPolicyModal = (
)}
</Modal>
),
[AmRouteReceivers, alertManagerSourceName, handleDismiss, handleSave, isDefaultPolicy, loading, route, showModal]
[alertManagerSourceName, handleDismiss, handleSave, isDefaultPolicy, loading, route, showModal]
);
return [modalElement, handleShow, handleDismiss];

View File

@ -0,0 +1,19 @@
import { ControllerRenderProps } from 'react-hook-form';
import { SelectableValue } from '@grafana/data';
import { ContactPointWithMetadata } from 'app/features/alerting/unified/components/contact-points/utils';
export const handleContactPointSelect = (
value: SelectableValue<ContactPointWithMetadata>,
onChange: ControllerRenderProps['onChange']
) => {
if (value === null) {
return onChange(null);
}
if (!value) {
return onChange('');
}
return onChange(value.value?.name);
};

View File

@ -1,40 +0,0 @@
import { Receiver } from 'app/plugins/datasource/alertmanager/types';
import { onCallApi } from '../../../api/onCallApi';
import { usePluginBridge } from '../../../hooks/usePluginBridge';
import { SupportedPlugin } from '../../../types/pluginBridges';
import { isOnCallReceiver } from './onCall/onCall';
import { AmRouteReceiver } from './types';
export const useGetGrafanaReceiverTypeChecker = () => {
const { installed: isOnCallEnabled } = usePluginBridge(SupportedPlugin.OnCall);
const { data } = onCallApi.useGrafanaOnCallIntegrationsQuery(undefined, {
skip: !isOnCallEnabled,
});
const getGrafanaReceiverType = (receiver: Receiver): SupportedPlugin | undefined => {
//CHECK FOR ONCALL PLUGIN
const onCallIntegrations = data ?? [];
if (isOnCallEnabled && isOnCallReceiver(receiver, onCallIntegrations)) {
return SupportedPlugin.OnCall;
}
//WE WILL ADD IN HERE IF THERE ARE MORE TYPES TO CHECK
return undefined;
};
return getGrafanaReceiverType;
};
export const useGetAmRouteReceiverWithGrafanaAppTypes = (receivers: Receiver[]) => {
const getGrafanaReceiverType = useGetGrafanaReceiverTypeChecker();
const receiverToSelectableContactPointValue = (receiver: Receiver): AmRouteReceiver => {
const amRouteReceiverValue: AmRouteReceiver = {
label: receiver.name,
value: receiver.name,
grafanaAppReceiverType: getGrafanaReceiverType(receiver),
};
return amRouteReceiverValue;
};
return receivers.map(receiverToSelectableContactPointValue);
};

View File

@ -1,11 +1,5 @@
import { SupportedPlugin } from '../../../types/pluginBridges';
export interface AmRouteReceiver {
label: string;
value: string;
grafanaAppReceiverType?: SupportedPlugin;
}
export const GRAFANA_APP_RECEIVERS_SOURCE_IMAGE: Record<SupportedPlugin, string> = {
[SupportedPlugin.OnCall]: 'public/img/alerting/oncall_logo.svg',

View File

@ -3,12 +3,10 @@ import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, CollapsableSection, LoadingPlaceholder, Stack, useStyles2 } from '@grafana/ui';
import { CollapsableSection, Stack, useStyles2 } from '@grafana/ui';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource';
import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoint';
import { useGrafanaContactPoints } from '../../../contact-points/useContactPoints';
import { ContactPointWithMetadata } from '../../../contact-points/utils';
import { ContactPointDetails } from './contactPoint/ContactPointDetails';
@ -24,12 +22,6 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
const styles = useStyles2(getStyles);
const alertManagerName = alertManager.name;
const {
isLoading,
error: errorInContactPointStatus,
contactPoints,
refetch: refetchReceivers,
} = useGrafanaContactPoints();
const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState<
ContactPointWithMetadata | undefined
@ -45,19 +37,6 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
watch(`contactPoints.${alertManagerName}.overrideTimings`) ||
watch(`contactPoints.${alertManagerName}.muteTimeIntervals`)?.length > 0;
const options = contactPoints.map((receiver) => {
const integrations = receiver?.grafana_managed_receiver_configs;
const description = <ContactPointReceiverSummary receivers={integrations ?? []} />;
return { label: receiver.name, value: receiver, description };
});
if (errorInContactPointStatus) {
return <Alert title="Failed to fetch contact points" severity="error" />;
}
if (isLoading) {
return <LoadingPlaceholder text={'Loading...'} />;
}
return (
<Stack direction="column">
<Stack direction="row" alignItems="center">
@ -70,12 +49,7 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
<div className={styles.secondAlertManagerLine}></div>
</Stack>
<Stack direction="row" gap={1} alignItems="center">
<ContactPointSelector
alertManager={alertManagerName}
options={options}
onSelectContactPoint={onSelectContactPoint}
refetchReceivers={refetchReceivers}
/>
<ContactPointSelector alertManager={alertManagerName} onSelectContactPoint={onSelectContactPoint} />
</Stack>
{selectedContactPointWithMetadata?.grafana_managed_receiver_configs && (
<ContactPointDetails receivers={selectedContactPointWithMetadata.grafana_managed_receiver_configs} />

View File

@ -1,19 +1,9 @@
import { css, cx, keyframes } from '@emotion/css';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
ActionMeta,
Field,
FieldValidationMessage,
IconButton,
Select,
Stack,
TextLink,
useStyles2,
} from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { ActionMeta, Field, FieldValidationMessage, Stack, TextLink } from '@grafana/ui';
import { ContactPointSelector as ContactPointSelectorDropdown } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { createRelativeUrl } from 'app/features/alerting/unified/utils/url';
@ -21,40 +11,14 @@ import { ContactPointWithMetadata } from '../../../../contact-points/utils';
export interface ContactPointSelectorProps {
alertManager: string;
options: Array<{
label: string;
value: ContactPointWithMetadata;
description: React.JSX.Element;
}>;
onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void;
refetchReceivers: () => Promise<unknown>;
}
const MAX_CONTACT_POINTS_RENDERED = 500;
export function ContactPointSelector({
alertManager,
options,
onSelectContactPoint,
refetchReceivers,
}: ContactPointSelectorProps) {
const styles = useStyles2(getStyles);
export function ContactPointSelector({ alertManager, onSelectContactPoint }: ContactPointSelectorProps) {
const { control, watch, trigger } = useFormContext<RuleFormValues>();
const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`);
const selectedContactPointWithMetadata = options.find((option) => option.value.name === contactPointInForm)?.value;
const selectedContactPointSelectableValue: SelectableValue<ContactPointWithMetadata> =
selectedContactPointWithMetadata
? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name }
: { value: undefined, label: '' };
const LOADING_SPINNER_DURATION = 1000;
const [loadingContactPoints, setLoadingContactPoints] = useState(false);
// we need to keep track if the fetching takes more than 1 second, so we can show the loading spinner until the fetching is done
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// if we have a contact point selected, check if it still exists in the event that someone has deleted it
const validateContactPoint = useCallback(() => {
if (contactPointInForm) {
@ -62,14 +26,6 @@ export function ContactPointSelector({
}
}, [alertManager, contactPointInForm, trigger]);
const onClickRefresh = () => {
setLoadingContactPoints(true);
Promise.all([refetchReceivers(), sleep(LOADING_SPINNER_DURATION)]).finally(() => {
setLoadingContactPoints(false);
validateContactPoint();
});
};
// validate the contact point and check if it still exists when mounting the component
useEffect(() => {
validateContactPoint();
@ -80,38 +36,22 @@ export function ContactPointSelector({
<Stack direction="row" alignItems="center">
<Field label="Contact point" data-testid="contact-point-picker">
<Controller
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => (
render={({ field: { onChange }, fieldState: { error } }) => (
<>
<div className={styles.contactPointsSelector}>
<Select<ContactPointWithMetadata>
virtualized={options.length > MAX_CONTACT_POINTS_RENDERED}
aria-label="Contact point"
defaultValue={selectedContactPointSelectableValue}
onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
onChange(value?.value?.name);
onSelectContactPoint(value?.value);
<Stack>
<ContactPointSelectorDropdown
selectProps={{
onChange: (value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
onChange(value?.value?.name);
onSelectContactPoint(value?.value);
},
width: 50,
}}
// We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined.
// The regular Select component will render it just fine, but we can't update the typings because SelectableValue
// is shared with other components where the "description" _has_ to be a string.
// I've tried unsuccessfully to separate the typings just I'm giving up :'(
// @ts-ignore
options={options}
width={50}
showRefreshButton
selectedContactPointName={contactPointInForm}
/>
<div className={styles.contactPointsInfo}>
<IconButton
name="sync"
onClick={onClickRefresh}
aria-label="Refresh contact points"
tooltip="Refresh contact points list"
className={cx(styles.refreshButton, {
[styles.loading]: loadingContactPoints,
})}
/>
<LinkToContactPoints />
</div>
</div>
<LinkToContactPoints />
</Stack>
{/* Error can come from the required validation we have in here, or from the manual setError we do in the parent component.
The only way I found to check the custom error is to check if the field has a value and if it's not in the options. */}
@ -124,14 +64,6 @@ export function ContactPointSelector({
value: true,
message: 'Contact point is required.',
},
validate: {
contactPointExists: (value: string) => {
if (options.some((option) => option.value.name === value)) {
return true;
}
return `Contact point ${contactPointInForm} does not exist.`;
},
},
}}
control={control}
name={`contactPoints.${alertManager}.selectedContactPoint`}
@ -149,47 +81,3 @@ function LinkToContactPoints() {
</TextLink>
);
}
const rotation = keyframes({
from: {
transform: 'rotate(720deg)',
},
to: {
transform: 'rotate(0deg)',
},
});
const getStyles = (theme: GrafanaTheme2) => ({
contactPointsSelector: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing(1),
marginTop: theme.spacing(1),
}),
contactPointsInfo: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing(1),
}),
refreshButton: css({
color: theme.colors.text.secondary,
cursor: 'pointer',
borderRadius: theme.shape.radius.circle,
overflow: 'hidden',
}),
loading: css({
pointerEvents: 'none',
[theme.transitions.handleMotion('no-preference')]: {
animation: `${rotation} 2s infinite linear`,
},
[theme.transitions.handleMotion('reduce')]: {
animation: `${rotation} 6s infinite linear`,
},
}),
warn: css({
color: theme.colors.warning.text,
}),
});

View File

@ -3,13 +3,16 @@ import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Button, Field, Icon, Input, Label, RadioButtonGroup, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
import { Trans } from 'app/core/internationalization';
import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import {
logInfo,
LogMessages,
logInfo,
trackRulesListViewChange,
trackRulesSearchComponentInteraction,
trackRulesSearchInputInteraction,
@ -18,6 +21,8 @@ import { useRulesFilter } from '../../hooks/useFilteredRules';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useAlertingHomePageExtensions } from '../../plugins/useAlertingHomePageExtensions';
import { RuleHealth } from '../../search/rulesSearchParser';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { alertStateToReadable } from '../../utils/rules';
import { HoverCard } from '../HoverCard';
@ -79,7 +84,9 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
const queryStringKey = `queryString-${filterKey}`;
const searchQueryRef = useRef<HTMLInputElement | null>(null);
const { handleSubmit, register, setValue } = useForm<{ searchQuery: string }>({ defaultValues: { searchQuery } });
const { handleSubmit, register, setValue } = useForm<{ searchQuery: string }>({
defaultValues: { searchQuery },
});
const { ref, ...rest } = register('searchQuery');
useEffect(() => {
@ -139,7 +146,14 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
trackRulesListViewChange({ view });
};
const handleContactPointChange = (contactPoint: string) => {
updateFilters({ ...filterState, contactPoint });
trackRulesSearchComponentInteraction('contactPoint');
};
const canRenderContactPointSelector = config.featureToggles.alertingSimplifiedRouting ?? false;
const searchIcon = <Icon name={'search'} />;
return (
<div className={styles.container}>
<Stack direction="column" gap={1}>
@ -222,6 +236,31 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
onChange={handleRuleHealthChange}
/>
</div>
{canRenderContactPointSelector && (
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={GRAFANA_RULES_SOURCE_NAME}>
<Stack direction="column" gap={0}>
<Field
label={
<Label htmlFor="contactPointFilter">
<Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans>
</Label>
}
>
<ContactPointSelector
selectedContactPointName={filterState.contactPoint}
selectProps={{
inputId: 'contactPointFilter',
width: 40,
onChange: (selectValue) => {
handleContactPointChange(selectValue?.value?.name!);
},
isClearable: true,
}}
/>
</Field>
</Stack>
</AlertmanagerProvider>
)}
{pluginsFilterEnabled && (
<div>
<Label>Plugin rules</Label>
@ -236,6 +275,7 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
</div>
)}
</Stack>
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1}>
<form
@ -334,6 +374,7 @@ function SearchQueryHelp() {
<HelpRow title="Type" expr="type:alerting|recording" />
<HelpRow title="Health" expr="health:ok|nodata|error" />
<HelpRow title="Dashboard UID" expr="dashboard:eadde4c7-54e6-4964-85c0-484ab852fd04" />
<HelpRow title="Contact point" expr="contactPoint:slack" />
</div>
</div>
);

View File

@ -250,7 +250,16 @@ const reduceGroups = (filterState: RulesFilter) => {
const matchesFilterFor = chain(filterState)
// ⚠️ keep this list of properties we filter for here up-to-date ⚠️
// We are ignoring predicates we've matched before we get here (like "freeFormWords")
.pick(['ruleType', 'dataSourceNames', 'ruleHealth', 'labels', 'ruleState', 'dashboardUid', 'plugins'])
.pick([
'ruleType',
'dataSourceNames',
'ruleHealth',
'labels',
'ruleState',
'dashboardUid',
'plugins',
'contactPoint',
])
.omitBy(isEmpty)
.mapValues(() => false)
.value();
@ -263,6 +272,17 @@ const reduceGroups = (filterState: RulesFilter) => {
matchesFilterFor.plugins = !isPluginProvidedRule(rule);
}
if ('contactPoint' in matchesFilterFor) {
const contactPoint = filterState.contactPoint;
const hasContactPoint =
isGrafanaRulerRule(rule.rulerRule) &&
rule.rulerRule.grafana_alert.notification_settings?.receiver === contactPoint;
if (hasContactPoint) {
matchesFilterFor.contactPoint = true;
}
}
if ('dataSourceNames' in matchesFilterFor) {
if (isGrafanaRulerRule(rule.rulerRule)) {
const doesNotQueryDs = isQueryingDataSource(rule.rulerRule, filterState);

View File

@ -5,6 +5,7 @@ import receiversMock from 'app/features/alerting/unified/components/contact-poin
import { MOCK_SILENCE_ID_EXISTING, mockAlertmanagerAlert } from 'app/features/alerting/unified/mocks';
import { defaultGrafanaAlertingConfigurationStatusResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi';
import { MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER } from 'app/features/alerting/unified/mocks/server/handlers/datasources';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { AlertManagerCortexConfig, AlertState } from 'app/plugins/datasource/alertmanager/types';
export const grafanaAlertingConfigurationStatusHandler = (
@ -84,8 +85,21 @@ const getGrafanaAlertmanagerTemplatePreview = () =>
HttpResponse.json({})
);
const getGrafanaReceiversHandler = () =>
http.get('/api/alertmanager/grafana/config/api/v1/receivers', () => HttpResponse.json(receiversMock));
const getReceiversHandler = () =>
http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/config/api/v1/receivers', ({ params }) => {
if (params.datasourceUid === GRAFANA_RULES_SOURCE_NAME) {
return HttpResponse.json(receiversMock);
}
// API does not exist for non-Grafana alertmanager,
// and UI uses this as heuristic to work out how to render in notification policies
return HttpResponse.json({ message: 'Not found.' }, { status: 404 });
});
const getGroupsHandler = () =>
http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/api/v2/alerts/groups', () =>
// TODO: Scaffold out response with better data as required by tests
HttpResponse.json([])
);
const handlers = [
alertmanagerAlertsListHandler(),
@ -95,6 +109,7 @@ const handlers = [
updateGrafanaAlertmanagerConfigHandler(),
updateAlertmanagerConfigHandler(),
getGrafanaAlertmanagerTemplatePreview(),
getGrafanaReceiversHandler(),
getReceiversHandler(),
getGroupsHandler(),
];
export default handlers;

View File

@ -59,6 +59,14 @@ describe('Alert rules searchParser', () => {
expect(filter.ruleHealth).toBe(expectedFilter);
});
it.each([{ query: 'contactPoint:slack', expectedFilter: 'slack' }])(
`should parse contactPoint $expectedFilter filter from "$query" query`,
({ query, expectedFilter }) => {
const filter = getSearchFilterFromQuery(query);
expect(filter.contactPoint).toBe(expectedFilter);
}
);
it('should parse non-filtering words as free form query', () => {
const filter = getSearchFilterFromQuery('cpu usage rule');
expect(filter.freeFormWords).toHaveLength(3);
@ -138,12 +146,13 @@ describe('Alert rules searchParser', () => {
ruleType: PromRuleType.Alerting,
ruleState: PromAlertingRuleState.Firing,
ruleHealth: RuleHealth.Ok,
contactPoint: 'slack',
});
const query = applySearchFilterToQuery('', filter);
expect(query).toBe(
'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" state:firing type:alerting health:ok label:team label:region=apac cpu eighty'
'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" state:firing type:alerting health:ok label:team label:region=apac cpu eighty contactPoint:slack'
);
});
@ -154,13 +163,14 @@ describe('Alert rules searchParser', () => {
labels: ['team', 'region=apac'],
groupName: 'cpu-usage',
ruleName: 'cpu > 80%',
contactPoint: 'slack',
});
const baseQuery = 'datasource:prometheus namespace:mimir-global group:memory rule:"mem > 90% label:severity"';
const query = applySearchFilterToQuery(baseQuery, filter);
expect(query).toBe(
'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" label:team label:region=apac'
'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" label:team label:region=apac contactPoint:slack'
);
});
@ -170,14 +180,16 @@ describe('Alert rules searchParser', () => {
namespace: '/etc/prometheus',
labels: ['region=emea'],
groupName: 'cpu-usage',
contactPoint: 'cp3',
ruleName: 'cpu > 80%',
});
const baseQuery = 'label:region=apac rule:"mem > 90%" group:memory namespace:mimir-global datasource:prometheus';
const baseQuery =
'label:region=apac rule:"mem > 90%" group:memory contactPoint:cp4 namespace:mimir-global datasource:prometheus';
const query = applySearchFilterToQuery(baseQuery, filter);
expect(query).toBe(
'label:region=emea rule:"cpu > 80%" group:cpu-usage namespace:/etc/prometheus datasource:"Mimir Dev"'
'label:region=emea rule:"cpu > 80%" group:cpu-usage contactPoint:cp3 namespace:/etc/prometheus datasource:"Mimir Dev"'
);
});
});

View File

@ -22,6 +22,7 @@ export interface RulesFilter {
ruleHealth?: RuleHealth;
dashboardUid?: string;
plugins?: 'hide';
contactPoint?: string | null;
}
const filterSupportedTerms: FilterSupportedTerm[] = [
@ -35,6 +36,7 @@ const filterSupportedTerms: FilterSupportedTerm[] = [
FilterSupportedTerm.health,
FilterSupportedTerm.dashboard,
FilterSupportedTerm.plugins,
FilterSupportedTerm.contactPoint,
];
export enum RuleHealth {
@ -59,6 +61,7 @@ export function getSearchFilterFromQuery(query: string): RulesFilter {
[terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)),
[terms.DashboardToken]: (value) => (filter.dashboardUid = value),
[terms.PluginsToken]: (value) => (filter.plugins = value === 'hide' ? value : undefined),
[terms.ContactPointToken]: (value) => (filter.contactPoint = value),
[terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value),
};
@ -107,6 +110,9 @@ export function applySearchFilterToQuery(query: string, filter: RulesFilter): st
if (filter.freeFormWords) {
filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word })));
}
if (filter.contactPoint) {
filterStateArray.push({ type: terms.ContactPointToken, value: filter.contactPoint });
}
return applyFiltersToQuery(query, filterSupportedTerms, filterStateArray);
}

View File

@ -1,6 +1,6 @@
@top AlertRuleSearch { expression+ }
@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter, dashboardFilter, pluginsFilter }
@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter, dashboardFilter, pluginsFilter, contactPointFilter }
expression { (FilterExpression | FreeFormExpression) expression }
@ -16,7 +16,8 @@ FilterExpression {
filter<TypeToken> |
filter<HealthToken> |
filter<DashboardToken> |
filter<PluginsToken>
filter<PluginsToken> |
filter<ContactPointToken>
}
filter<token> { token FilterValue }
@ -45,6 +46,7 @@ filter<token> { token FilterValue }
HealthToken[@dialect=healthFilter] { filterToken<"health"> }
DashboardToken[@dialect=dashboardFilter] { filterToken<"dashboard"> }
PluginsToken[@dialect=pluginsFilter] { filterToken<"plugins"> }
ContactPointToken[@dialect=contactPointFilter] { filterToken<"contactPoint"> }
@precedence { DataSourceToken, word }
@precedence { NameSpaceToken, word }
@ -56,5 +58,6 @@ filter<token> { token FilterValue }
@precedence { HealthToken, word }
@precedence { DashboardToken, word }
@precedence { PluginsToken, word }
@precedence { ContactPointToken, word }
}

File diff suppressed because one or more lines are too long

View File

@ -12,7 +12,8 @@ export const AlertRuleSearch = 1,
HealthToken = 11,
DashboardToken = 12,
PluginsToken = 13,
FreeFormExpression = 14,
ContactPointToken = 14,
FreeFormExpression = 15,
Dialect_dataSourceFilter = 0,
Dialect_nameSpaceFilter = 1,
Dialect_labelFilter = 2,
@ -22,4 +23,5 @@ export const AlertRuleSearch = 1,
Dialect_typeFilter = 6,
Dialect_healthFilter = 7,
Dialect_dashboardFilter = 8,
Dialect_pluginsFilter = 9;
Dialect_pluginsFilter = 9,
Dialect_contactPointFilter = 10;

View File

@ -15,6 +15,7 @@ const filterTokenToTypeMap: Record<number, string> = {
[terms.HealthToken]: 'health',
[terms.DashboardToken]: 'dashboard',
[terms.PluginsToken]: 'plugins',
[terms.ContactPointToken]: 'contactPoint',
};
// This enum allows to configure parser behavior
@ -31,6 +32,7 @@ export enum FilterSupportedTerm {
health = 'healthFilter',
dashboard = 'dashboardFilter',
plugins = 'pluginsFilter',
contactPoint = 'contactPointFilter',
}
export type QueryFilterMapper = Record<number, (filter: string) => void>;

View File

@ -212,19 +212,6 @@ export const stringToSelectableValue = (str: string): SelectableValue<string> =>
export const stringsToSelectableValues = (arr: string[] | undefined): Array<SelectableValue<string>> =>
(arr ?? []).map(stringToSelectableValue);
export const mapSelectValueToString = (selectableValue: SelectableValue<string>): string | null => {
// this allows us to deal with cleared values
if (selectableValue === null) {
return null;
}
if (!selectableValue) {
return '';
}
return selectableValueToString(selectableValue) ?? '';
};
export const mapMultiSelectValueToStrings = (
selectableValues: Array<SelectableValue<string>> | undefined
): string[] => {

View File

@ -120,6 +120,9 @@
"used-by_one": "Used by {{ count }} notification policy",
"used-by_other": "Used by {{ count }} notification policies"
},
"contactPointFilter": {
"label": "Contact point"
},
"grafana-rules": {
"export-rules": "Export rules",
"loading": "Loading...",

View File

@ -120,6 +120,9 @@
"used-by_one": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy",
"used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčįęş"
},
"contactPointFilter": {
"label": "Cőʼnŧäčŧ pőįʼnŧ"
},
"grafana-rules": {
"export-rules": "Ēχpőřŧ řūľęş",
"loading": "Ŀőäđįʼnģ...",