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

View File

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

View File

@ -29,6 +29,7 @@ export const AlertGroupFilter = ({ groups }: Props) => {
groupBy: null, groupBy: null,
queryString: null, queryString: null,
alertState: null, alertState: null,
contactPoint: null,
}); });
setTimeout(() => setFilterKey(filterKey + 1), 100); 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 { PrimaryText } from 'app/features/alerting/unified/components/common/TextVariants';
import { ContactPointHeader } from 'app/features/alerting/unified/components/contact-points/ContactPointHeader'; import { ContactPointHeader } from 'app/features/alerting/unified/components/contact-points/ContactPointHeader';
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting'; import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting';
import { INTEGRATION_ICONS } from '../../types/contact-points'; import { INTEGRATION_ICONS } from '../../types/contact-points';
@ -152,37 +151,56 @@ interface ContactPointReceiverMetadata {
} }
type ContactPointReceiverSummaryProps = { type ContactPointReceiverSummaryProps = {
receivers: GrafanaManagedReceiverConfig[]; receivers: ReceiverConfigWithMetadata[];
limit?: number;
}; };
/** /**
* This summary is used when we're dealing with non-Grafana managed alertmanager since they * 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 * 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 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 ( return (
<Stack direction="column" gap={0}> <Stack direction="column" gap={0}>
<Stack direction="row" alignItems="center" gap={1}> <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 iconName = INTEGRATION_ICONS[type];
const receiverName = receiverTypeNames[type] ?? upperFirst(type); const receiverName = receiverTypeNames[type] ?? upperFirst(type);
const isLastItem = size(countByType) - 1 === index; 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 ( return (
<Fragment key={type}> <Fragment key={type}>
<Stack direction="row" alignItems="center" gap={0.5}> <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} />} {iconName && <Icon name={iconName} />}
<Text variant="body"> <span>
{receiverName} {receiverName}
{receivers.length > 1 && receivers.length} {receivers.length > 1 && receivers.length}
</Text> </span>
</Stack> </Stack>
{!isLastItem && '⋅'} {!isLastItem && '⋅'}
</Fragment> </Fragment>
); );
})} })}
{numberOfIntegrationsNotShown > 0 && <span>{`+${numberOfIntegrationsNotShown} more`}</span>}
</Stack> </Stack>
</Stack> </Stack>
); );

View File

@ -1,52 +1,115 @@
import { SelectableValue } from '@grafana/data'; import { css, cx, keyframes } from '@emotion/css';
import { Select, SelectCommonProps, Text, Stack } from '@grafana/ui'; 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 { 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 { 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 { 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 const options: Array<SelectableValue<ContactPointWithMetadata>> = contactPoints.map((contactPoint) => {
if (error) {
return <span>Failed to load contact points</span>;
}
const options: Array<SelectableValue<string>> = contactPoints.map((contactPoint) => {
return { return {
label: contactPoint.name, label: contactPoint.name,
value: contactPoint.name, value: contactPoint,
component: () => <ReceiversSummary receivers={contactPoint.grafana_managed_receiver_configs} />, 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]);
// force some minimum wait period for fetching contact points
const onClickRefresh = () => {
setLoaderSpinning(true);
Promise.all([refetch(), sleep(LOADING_SPINNER_DURATION)]).finally(() => {
setLoaderSpinning(false);
});
}; };
interface ReceiversProps { // TODO error handling
receivers: ReceiverConfigWithMetadata[]; if (error) {
return <Alert title="Failed to fetch contact points" severity="error" />;
} }
const ReceiversSummary = ({ receivers }: ReceiversProps) => {
return ( return (
<Stack direction="row"> <Stack>
{receivers.map((receiver, index) => ( <Select
<Stack key={receiver.uid ?? index} direction="row" gap={0.5}> virtualized={options.length > MAX_CONTACT_POINTS_RENDERED}
{receiver[RECEIVER_PLUGIN_META_KEY]?.icon && ( options={options}
<img value={matchedContactPoint}
width="16px" {...selectProps}
src={receiver[RECEIVER_PLUGIN_META_KEY]?.icon} isLoading={isLoading}
alt={receiver[RECEIVER_PLUGIN_META_KEY]?.title} />
{showRefreshButton && (
<IconButton
name="sync"
onClick={onClickRefresh}
aria-label="Refresh contact points"
tooltip="Refresh contact points list"
className={cx(styles.refreshButton, {
[styles.loading]: loaderSpinning || isLoading,
})}
/> />
)} )}
<Text key={index} variant="bodySmall" color="secondary">
{receiver[RECEIVER_META_KEY].name ?? receiver[RECEIVER_PLUGIN_META_KEY]?.title ?? receiver.type}
</Text>
</Stack>
))}
</Stack> </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 { byRole } from 'testing-library-selector';
import { Button } from '@grafana/ui'; 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 { RouteWithID } from '../../../../../plugins/datasource/alertmanager/types';
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
import { FormAmRoute } from '../../types/amroutes'; import { FormAmRoute } from '../../types/amroutes';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
import { AmRootRouteForm } from './EditDefaultPolicyForm'; import { AmRootRouteForm } from './EditDefaultPolicyForm';
@ -20,12 +22,15 @@ const ui = {
groupIntervalInput: byRole('textbox', { name: /Group interval/ }), groupIntervalInput: byRole('textbox', { name: /Group interval/ }),
repeatIntervalInput: byRole('textbox', { name: /Repeat interval/ }), repeatIntervalInput: byRole('textbox', { name: /Repeat interval/ }),
}; };
setupMswServer();
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
// TODO Default and Notification policy form should be unified so we don't need to maintain two almost identical forms // TODO Default and Notification policy form should be unified so we don't need to maintain two almost identical forms
describe('EditDefaultPolicyForm', function () { describe('EditDefaultPolicyForm', function () {
beforeEach(() => {
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
]);
});
describe('Timing options', function () { describe('Timing options', function () {
it('should render prometheus duration strings in form inputs', async function () { it('should render prometheus duration strings in form inputs', async function () {
const { user } = renderRouteForm({ const { user } = renderRouteForm({
@ -47,7 +52,6 @@ describe('EditDefaultPolicyForm', function () {
id: '0', id: '0',
receiver: 'default', receiver: 'default',
}, },
[{ value: 'default', label: 'Default' }],
onSubmit onSubmit
); );
@ -78,7 +82,6 @@ describe('EditDefaultPolicyForm', function () {
id: '0', id: '0',
receiver: 'default', receiver: 'default',
}, },
[{ value: 'default', label: 'Default' }],
onSubmit onSubmit
); );
@ -105,7 +108,6 @@ describe('EditDefaultPolicyForm', function () {
group_interval: '2d4h30m35s', group_interval: '2d4h30m35s',
repeat_interval: '1w2d6h', repeat_interval: '1w2d6h',
}, },
[{ value: 'default', label: 'Default' }],
onSubmit onSubmit
); );
@ -128,18 +130,15 @@ describe('EditDefaultPolicyForm', function () {
}); });
}); });
function renderRouteForm( function renderRouteForm(route: RouteWithID, onSubmit: (route: Partial<FormAmRoute>) => void = noop) {
route: RouteWithID,
receivers: AmRouteReceiver[] = [],
onSubmit: (route: Partial<FormAmRoute>) => void = noop
) {
return render( return render(
<AlertmanagerProvider accessType="instance">
<AmRootRouteForm <AmRootRouteForm
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME} alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
actionButtons={<Button type="submit">Update default policy</Button>} actionButtons={<Button type="submit">Update default policy</Button>}
onSubmit={onSubmit} onSubmit={onSubmit}
receivers={receivers}
route={route} route={route}
/> />
</AlertmanagerProvider>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -2,19 +2,12 @@ import { groupBy } from 'lodash';
import { FC, useCallback, useMemo, useState } from 'react'; import { FC, useCallback, useMemo, useState } from 'react';
import { Button, Icon, Modal, ModalProps, Spinner, Stack } from '@grafana/ui'; import { Button, Icon, Modal, ModalProps, Spinner, Stack } from '@grafana/ui';
import { import { AlertmanagerGroup, AlertState, ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
AlertmanagerGroup,
AlertState,
ObjectMatcher,
Receiver,
RouteWithID,
} from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../../types/amroutes'; import { FormAmRoute } from '../../types/amroutes';
import { MatcherFormatter } from '../../utils/matchers'; import { MatcherFormatter } from '../../utils/matchers';
import { InsertPosition } from '../../utils/routeTree'; import { InsertPosition } from '../../utils/routeTree';
import { AlertGroup } from '../alert-groups/AlertGroup'; import { AlertGroup } from '../alert-groups/AlertGroup';
import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp';
import { AlertGroupsSummary } from './AlertGroupsSummary'; import { AlertGroupsSummary } from './AlertGroupsSummary';
import { AmRootRouteForm } from './EditDefaultPolicyForm'; 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]; type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void];
const useAddPolicyModal = ( const useAddPolicyModal = (
receivers: Receiver[] = [],
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void, handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void,
loading: boolean loading: boolean
): AddModalHook<RouteWithID> => { ): AddModalHook<RouteWithID> => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [insertPosition, setInsertPosition] = useState<InsertPosition | undefined>(undefined); const [insertPosition, setInsertPosition] = useState<InsertPosition | undefined>(undefined);
const [referenceRoute, setReferenceRoute] = useState<RouteWithID>(); const [referenceRoute, setReferenceRoute] = useState<RouteWithID>();
const AmRouteReceivers = useGetAmRouteReceiverWithGrafanaAppTypes(receivers);
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setReferenceRoute(undefined); setReferenceRoute(undefined);
@ -60,7 +51,6 @@ const useAddPolicyModal = (
title="Add notification policy" title="Add notification policy"
> >
<AmRoutesExpandedForm <AmRoutesExpandedForm
receivers={AmRouteReceivers}
defaults={{ defaults={{
groupBy: referenceRoute?.group_by, groupBy: referenceRoute?.group_by,
}} }}
@ -80,7 +70,7 @@ const useAddPolicyModal = (
/> />
</Modal> </Modal>
), ),
[AmRouteReceivers, handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal] [handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal]
); );
return [modalElement, handleShow, handleDismiss]; return [modalElement, handleShow, handleDismiss];
@ -88,14 +78,12 @@ const useAddPolicyModal = (
const useEditPolicyModal = ( const useEditPolicyModal = (
alertManagerSourceName: string, alertManagerSourceName: string,
receivers: Receiver[],
handleSave: (route: Partial<FormAmRoute>) => void, handleSave: (route: Partial<FormAmRoute>) => void,
loading: boolean loading: boolean
): EditModalHook => { ): EditModalHook => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [isDefaultPolicy, setIsDefaultPolicy] = useState(false); const [isDefaultPolicy, setIsDefaultPolicy] = useState(false);
const [route, setRoute] = useState<RouteWithID>(); const [route, setRoute] = useState<RouteWithID>();
const AmRouteReceivers = useGetAmRouteReceiverWithGrafanaAppTypes(receivers);
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setRoute(undefined); setRoute(undefined);
@ -126,7 +114,6 @@ const useEditPolicyModal = (
// passing it down all the way here is a code smell // passing it down all the way here is a code smell
alertManagerSourceName={alertManagerSourceName} alertManagerSourceName={alertManagerSourceName}
onSubmit={handleSave} onSubmit={handleSave}
receivers={AmRouteReceivers}
route={route} route={route}
actionButtons={ actionButtons={
<Modal.ButtonRow> <Modal.ButtonRow>
@ -140,7 +127,6 @@ const useEditPolicyModal = (
)} )}
{!isDefaultPolicy && ( {!isDefaultPolicy && (
<AmRoutesExpandedForm <AmRoutesExpandedForm
receivers={AmRouteReceivers}
route={route} route={route}
onSubmit={handleSave} onSubmit={handleSave}
actionButtons={ actionButtons={
@ -155,7 +141,7 @@ const useEditPolicyModal = (
)} )}
</Modal> </Modal>
), ),
[AmRouteReceivers, alertManagerSourceName, handleDismiss, handleSave, isDefaultPolicy, loading, route, showModal] [alertManagerSourceName, handleDismiss, handleSave, isDefaultPolicy, loading, route, showModal]
); );
return [modalElement, handleShow, handleDismiss]; 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'; import { SupportedPlugin } from '../../../types/pluginBridges';
export interface AmRouteReceiver {
label: string;
value: string;
grafanaAppReceiverType?: SupportedPlugin;
}
export const GRAFANA_APP_RECEIVERS_SOURCE_IMAGE: Record<SupportedPlugin, string> = { export const GRAFANA_APP_RECEIVERS_SOURCE_IMAGE: Record<SupportedPlugin, string> = {
[SupportedPlugin.OnCall]: 'public/img/alerting/oncall_logo.svg', [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 { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data'; 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 { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource'; 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 { ContactPointWithMetadata } from '../../../contact-points/utils';
import { ContactPointDetails } from './contactPoint/ContactPointDetails'; import { ContactPointDetails } from './contactPoint/ContactPointDetails';
@ -24,12 +22,6 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const alertManagerName = alertManager.name; const alertManagerName = alertManager.name;
const {
isLoading,
error: errorInContactPointStatus,
contactPoints,
refetch: refetchReceivers,
} = useGrafanaContactPoints();
const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState< const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState<
ContactPointWithMetadata | undefined ContactPointWithMetadata | undefined
@ -45,19 +37,6 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
watch(`contactPoints.${alertManagerName}.overrideTimings`) || watch(`contactPoints.${alertManagerName}.overrideTimings`) ||
watch(`contactPoints.${alertManagerName}.muteTimeIntervals`)?.length > 0; 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 ( return (
<Stack direction="column"> <Stack direction="column">
<Stack direction="row" alignItems="center"> <Stack direction="row" alignItems="center">
@ -70,12 +49,7 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
<div className={styles.secondAlertManagerLine}></div> <div className={styles.secondAlertManagerLine}></div>
</Stack> </Stack>
<Stack direction="row" gap={1} alignItems="center"> <Stack direction="row" gap={1} alignItems="center">
<ContactPointSelector <ContactPointSelector alertManager={alertManagerName} onSelectContactPoint={onSelectContactPoint} />
alertManager={alertManagerName}
options={options}
onSelectContactPoint={onSelectContactPoint}
refetchReceivers={refetchReceivers}
/>
</Stack> </Stack>
{selectedContactPointWithMetadata?.grafana_managed_receiver_configs && ( {selectedContactPointWithMetadata?.grafana_managed_receiver_configs && (
<ContactPointDetails receivers={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 { useCallback, useEffect } from 'react';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { import { ActionMeta, Field, FieldValidationMessage, Stack, TextLink } from '@grafana/ui';
ActionMeta, import { ContactPointSelector as ContactPointSelectorDropdown } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
Field,
FieldValidationMessage,
IconButton,
Select,
Stack,
TextLink,
useStyles2,
} from '@grafana/ui';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { createRelativeUrl } from 'app/features/alerting/unified/utils/url'; import { createRelativeUrl } from 'app/features/alerting/unified/utils/url';
@ -21,40 +11,14 @@ import { ContactPointWithMetadata } from '../../../../contact-points/utils';
export interface ContactPointSelectorProps { export interface ContactPointSelectorProps {
alertManager: string; alertManager: string;
options: Array<{
label: string;
value: ContactPointWithMetadata;
description: React.JSX.Element;
}>;
onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void; onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void;
refetchReceivers: () => Promise<unknown>;
} }
const MAX_CONTACT_POINTS_RENDERED = 500; export function ContactPointSelector({ alertManager, onSelectContactPoint }: ContactPointSelectorProps) {
export function ContactPointSelector({
alertManager,
options,
onSelectContactPoint,
refetchReceivers,
}: ContactPointSelectorProps) {
const styles = useStyles2(getStyles);
const { control, watch, trigger } = useFormContext<RuleFormValues>(); const { control, watch, trigger } = useFormContext<RuleFormValues>();
const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`); 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 // if we have a contact point selected, check if it still exists in the event that someone has deleted it
const validateContactPoint = useCallback(() => { const validateContactPoint = useCallback(() => {
if (contactPointInForm) { if (contactPointInForm) {
@ -62,14 +26,6 @@ export function ContactPointSelector({
} }
}, [alertManager, contactPointInForm, trigger]); }, [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 // validate the contact point and check if it still exists when mounting the component
useEffect(() => { useEffect(() => {
validateContactPoint(); validateContactPoint();
@ -80,38 +36,22 @@ export function ContactPointSelector({
<Stack direction="row" alignItems="center"> <Stack direction="row" alignItems="center">
<Field label="Contact point" data-testid="contact-point-picker"> <Field label="Contact point" data-testid="contact-point-picker">
<Controller <Controller
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => ( render={({ field: { onChange }, fieldState: { error } }) => (
<> <>
<div className={styles.contactPointsSelector}> <Stack>
<Select<ContactPointWithMetadata> <ContactPointSelectorDropdown
virtualized={options.length > MAX_CONTACT_POINTS_RENDERED} selectProps={{
aria-label="Contact point" onChange: (value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
defaultValue={selectedContactPointSelectableValue}
onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
onChange(value?.value?.name); onChange(value?.value?.name);
onSelectContactPoint(value?.value); 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. showRefreshButton
// The regular Select component will render it just fine, but we can't update the typings because SelectableValue selectedContactPointName={contactPointInForm}
// 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}
/>
<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 /> <LinkToContactPoints />
</div> </Stack>
</div>
{/* Error can come from the required validation we have in here, or from the manual setError we do in the parent component. {/* 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. */} 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, value: true,
message: 'Contact point is required.', 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} control={control}
name={`contactPoints.${alertManager}.selectedContactPoint`} name={`contactPoints.${alertManager}.selectedContactPoint`}
@ -149,47 +81,3 @@ function LinkToContactPoints() {
</TextLink> </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 { useForm } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data'; 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 { Button, Field, Icon, Input, Label, RadioButtonGroup, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; 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 { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import { import {
logInfo,
LogMessages, LogMessages,
logInfo,
trackRulesListViewChange, trackRulesListViewChange,
trackRulesSearchComponentInteraction, trackRulesSearchComponentInteraction,
trackRulesSearchInputInteraction, trackRulesSearchInputInteraction,
@ -18,6 +21,8 @@ import { useRulesFilter } from '../../hooks/useFilteredRules';
import { useURLSearchParams } from '../../hooks/useURLSearchParams'; import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useAlertingHomePageExtensions } from '../../plugins/useAlertingHomePageExtensions'; import { useAlertingHomePageExtensions } from '../../plugins/useAlertingHomePageExtensions';
import { RuleHealth } from '../../search/rulesSearchParser'; import { RuleHealth } from '../../search/rulesSearchParser';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { alertStateToReadable } from '../../utils/rules'; import { alertStateToReadable } from '../../utils/rules';
import { HoverCard } from '../HoverCard'; import { HoverCard } from '../HoverCard';
@ -79,7 +84,9 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
const queryStringKey = `queryString-${filterKey}`; const queryStringKey = `queryString-${filterKey}`;
const searchQueryRef = useRef<HTMLInputElement | null>(null); 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'); const { ref, ...rest } = register('searchQuery');
useEffect(() => { useEffect(() => {
@ -139,7 +146,14 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
trackRulesListViewChange({ view }); trackRulesListViewChange({ view });
}; };
const handleContactPointChange = (contactPoint: string) => {
updateFilters({ ...filterState, contactPoint });
trackRulesSearchComponentInteraction('contactPoint');
};
const canRenderContactPointSelector = config.featureToggles.alertingSimplifiedRouting ?? false;
const searchIcon = <Icon name={'search'} />; const searchIcon = <Icon name={'search'} />;
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Stack direction="column" gap={1}> <Stack direction="column" gap={1}>
@ -222,6 +236,31 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
onChange={handleRuleHealthChange} onChange={handleRuleHealthChange}
/> />
</div> </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 && ( {pluginsFilterEnabled && (
<div> <div>
<Label>Plugin rules</Label> <Label>Plugin rules</Label>
@ -236,6 +275,7 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
</div> </div>
)} )}
</Stack> </Stack>
<Stack direction="column" gap={1}> <Stack direction="column" gap={1}>
<Stack direction="row" gap={1}> <Stack direction="row" gap={1}>
<form <form
@ -334,6 +374,7 @@ function SearchQueryHelp() {
<HelpRow title="Type" expr="type:alerting|recording" /> <HelpRow title="Type" expr="type:alerting|recording" />
<HelpRow title="Health" expr="health:ok|nodata|error" /> <HelpRow title="Health" expr="health:ok|nodata|error" />
<HelpRow title="Dashboard UID" expr="dashboard:eadde4c7-54e6-4964-85c0-484ab852fd04" /> <HelpRow title="Dashboard UID" expr="dashboard:eadde4c7-54e6-4964-85c0-484ab852fd04" />
<HelpRow title="Contact point" expr="contactPoint:slack" />
</div> </div>
</div> </div>
); );

View File

@ -250,7 +250,16 @@ const reduceGroups = (filterState: RulesFilter) => {
const matchesFilterFor = chain(filterState) const matchesFilterFor = chain(filterState)
// ⚠️ keep this list of properties we filter for here up-to-date ⚠️ // ⚠️ 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") // 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) .omitBy(isEmpty)
.mapValues(() => false) .mapValues(() => false)
.value(); .value();
@ -263,6 +272,17 @@ const reduceGroups = (filterState: RulesFilter) => {
matchesFilterFor.plugins = !isPluginProvidedRule(rule); 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 ('dataSourceNames' in matchesFilterFor) {
if (isGrafanaRulerRule(rule.rulerRule)) { if (isGrafanaRulerRule(rule.rulerRule)) {
const doesNotQueryDs = isQueryingDataSource(rule.rulerRule, filterState); 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 { MOCK_SILENCE_ID_EXISTING, mockAlertmanagerAlert } from 'app/features/alerting/unified/mocks';
import { defaultGrafanaAlertingConfigurationStatusResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi'; 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 { 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'; import { AlertManagerCortexConfig, AlertState } from 'app/plugins/datasource/alertmanager/types';
export const grafanaAlertingConfigurationStatusHandler = ( export const grafanaAlertingConfigurationStatusHandler = (
@ -84,8 +85,21 @@ const getGrafanaAlertmanagerTemplatePreview = () =>
HttpResponse.json({}) HttpResponse.json({})
); );
const getGrafanaReceiversHandler = () => const getReceiversHandler = () =>
http.get('/api/alertmanager/grafana/config/api/v1/receivers', () => HttpResponse.json(receiversMock)); 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 = [ const handlers = [
alertmanagerAlertsListHandler(), alertmanagerAlertsListHandler(),
@ -95,6 +109,7 @@ const handlers = [
updateGrafanaAlertmanagerConfigHandler(), updateGrafanaAlertmanagerConfigHandler(),
updateAlertmanagerConfigHandler(), updateAlertmanagerConfigHandler(),
getGrafanaAlertmanagerTemplatePreview(), getGrafanaAlertmanagerTemplatePreview(),
getGrafanaReceiversHandler(), getReceiversHandler(),
getGroupsHandler(),
]; ];
export default handlers; export default handlers;

View File

@ -59,6 +59,14 @@ describe('Alert rules searchParser', () => {
expect(filter.ruleHealth).toBe(expectedFilter); 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', () => { it('should parse non-filtering words as free form query', () => {
const filter = getSearchFilterFromQuery('cpu usage rule'); const filter = getSearchFilterFromQuery('cpu usage rule');
expect(filter.freeFormWords).toHaveLength(3); expect(filter.freeFormWords).toHaveLength(3);
@ -138,12 +146,13 @@ describe('Alert rules searchParser', () => {
ruleType: PromRuleType.Alerting, ruleType: PromRuleType.Alerting,
ruleState: PromAlertingRuleState.Firing, ruleState: PromAlertingRuleState.Firing,
ruleHealth: RuleHealth.Ok, ruleHealth: RuleHealth.Ok,
contactPoint: 'slack',
}); });
const query = applySearchFilterToQuery('', filter); const query = applySearchFilterToQuery('', filter);
expect(query).toBe( 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'], labels: ['team', 'region=apac'],
groupName: 'cpu-usage', groupName: 'cpu-usage',
ruleName: 'cpu > 80%', ruleName: 'cpu > 80%',
contactPoint: 'slack',
}); });
const baseQuery = 'datasource:prometheus namespace:mimir-global group:memory rule:"mem > 90% label:severity"'; const baseQuery = 'datasource:prometheus namespace:mimir-global group:memory rule:"mem > 90% label:severity"';
const query = applySearchFilterToQuery(baseQuery, filter); const query = applySearchFilterToQuery(baseQuery, filter);
expect(query).toBe( 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', namespace: '/etc/prometheus',
labels: ['region=emea'], labels: ['region=emea'],
groupName: 'cpu-usage', groupName: 'cpu-usage',
contactPoint: 'cp3',
ruleName: 'cpu > 80%', 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); const query = applySearchFilterToQuery(baseQuery, filter);
expect(query).toBe( 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; ruleHealth?: RuleHealth;
dashboardUid?: string; dashboardUid?: string;
plugins?: 'hide'; plugins?: 'hide';
contactPoint?: string | null;
} }
const filterSupportedTerms: FilterSupportedTerm[] = [ const filterSupportedTerms: FilterSupportedTerm[] = [
@ -35,6 +36,7 @@ const filterSupportedTerms: FilterSupportedTerm[] = [
FilterSupportedTerm.health, FilterSupportedTerm.health,
FilterSupportedTerm.dashboard, FilterSupportedTerm.dashboard,
FilterSupportedTerm.plugins, FilterSupportedTerm.plugins,
FilterSupportedTerm.contactPoint,
]; ];
export enum RuleHealth { export enum RuleHealth {
@ -59,6 +61,7 @@ export function getSearchFilterFromQuery(query: string): RulesFilter {
[terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)), [terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)),
[terms.DashboardToken]: (value) => (filter.dashboardUid = value), [terms.DashboardToken]: (value) => (filter.dashboardUid = value),
[terms.PluginsToken]: (value) => (filter.plugins = value === 'hide' ? value : undefined), [terms.PluginsToken]: (value) => (filter.plugins = value === 'hide' ? value : undefined),
[terms.ContactPointToken]: (value) => (filter.contactPoint = value),
[terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value), [terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value),
}; };
@ -107,6 +110,9 @@ export function applySearchFilterToQuery(query: string, filter: RulesFilter): st
if (filter.freeFormWords) { if (filter.freeFormWords) {
filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word }))); 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); return applyFiltersToQuery(query, filterSupportedTerms, filterStateArray);
} }

View File

@ -1,6 +1,6 @@
@top AlertRuleSearch { expression+ } @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 } expression { (FilterExpression | FreeFormExpression) expression }
@ -16,7 +16,8 @@ FilterExpression {
filter<TypeToken> | filter<TypeToken> |
filter<HealthToken> | filter<HealthToken> |
filter<DashboardToken> | filter<DashboardToken> |
filter<PluginsToken> filter<PluginsToken> |
filter<ContactPointToken>
} }
filter<token> { token FilterValue } filter<token> { token FilterValue }
@ -45,6 +46,7 @@ filter<token> { token FilterValue }
HealthToken[@dialect=healthFilter] { filterToken<"health"> } HealthToken[@dialect=healthFilter] { filterToken<"health"> }
DashboardToken[@dialect=dashboardFilter] { filterToken<"dashboard"> } DashboardToken[@dialect=dashboardFilter] { filterToken<"dashboard"> }
PluginsToken[@dialect=pluginsFilter] { filterToken<"plugins"> } PluginsToken[@dialect=pluginsFilter] { filterToken<"plugins"> }
ContactPointToken[@dialect=contactPointFilter] { filterToken<"contactPoint"> }
@precedence { DataSourceToken, word } @precedence { DataSourceToken, word }
@precedence { NameSpaceToken, word } @precedence { NameSpaceToken, word }
@ -56,5 +58,6 @@ filter<token> { token FilterValue }
@precedence { HealthToken, word } @precedence { HealthToken, word }
@precedence { DashboardToken, word } @precedence { DashboardToken, word }
@precedence { PluginsToken, 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, HealthToken = 11,
DashboardToken = 12, DashboardToken = 12,
PluginsToken = 13, PluginsToken = 13,
FreeFormExpression = 14, ContactPointToken = 14,
FreeFormExpression = 15,
Dialect_dataSourceFilter = 0, Dialect_dataSourceFilter = 0,
Dialect_nameSpaceFilter = 1, Dialect_nameSpaceFilter = 1,
Dialect_labelFilter = 2, Dialect_labelFilter = 2,
@ -22,4 +23,5 @@ export const AlertRuleSearch = 1,
Dialect_typeFilter = 6, Dialect_typeFilter = 6,
Dialect_healthFilter = 7, Dialect_healthFilter = 7,
Dialect_dashboardFilter = 8, 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.HealthToken]: 'health',
[terms.DashboardToken]: 'dashboard', [terms.DashboardToken]: 'dashboard',
[terms.PluginsToken]: 'plugins', [terms.PluginsToken]: 'plugins',
[terms.ContactPointToken]: 'contactPoint',
}; };
// This enum allows to configure parser behavior // This enum allows to configure parser behavior
@ -31,6 +32,7 @@ export enum FilterSupportedTerm {
health = 'healthFilter', health = 'healthFilter',
dashboard = 'dashboardFilter', dashboard = 'dashboardFilter',
plugins = 'pluginsFilter', plugins = 'pluginsFilter',
contactPoint = 'contactPointFilter',
} }
export type QueryFilterMapper = Record<number, (filter: string) => void>; 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>> => export const stringsToSelectableValues = (arr: string[] | undefined): Array<SelectableValue<string>> =>
(arr ?? []).map(stringToSelectableValue); (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 = ( export const mapMultiSelectValueToStrings = (
selectableValues: Array<SelectableValue<string>> | undefined selectableValues: Array<SelectableValue<string>> | undefined
): string[] => { ): string[] => {

View File

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

View File

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