mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
149f02aebe
commit
735954386f
@ -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"]
|
||||
|
@ -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(() => {
|
||||
|
@ -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}
|
||||
|
@ -29,6 +29,7 @@ export const AlertGroupFilter = ({ groups }: Props) => {
|
||||
groupBy: null,
|
||||
queryString: null,
|
||||
alertState: null,
|
||||
contactPoint: null,
|
||||
});
|
||||
setTimeout(() => setFilterKey(filterKey + 1), 100);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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];
|
||||
|
@ -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);
|
||||
};
|
@ -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);
|
||||
};
|
@ -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',
|
||||
|
||||
|
@ -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} />
|
||||
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
@ -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;
|
||||
|
@ -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>;
|
||||
|
@ -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[] => {
|
||||
|
@ -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...",
|
||||
|
@ -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ģ...",
|
||||
|
Loading…
Reference in New Issue
Block a user