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 />", "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"]
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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}
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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]);
|
||||||
|
|
||||||
interface ReceiversProps {
|
// force some minimum wait period for fetching contact points
|
||||||
receivers: ReceiverConfigWithMetadata[];
|
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 (
|
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
|
||||||
<Text key={index} variant="bodySmall" color="secondary">
|
name="sync"
|
||||||
{receiver[RECEIVER_META_KEY].name ?? receiver[RECEIVER_PLUGIN_META_KEY]?.title ?? receiver.type}
|
onClick={onClickRefresh}
|
||||||
</Text>
|
aria-label="Refresh contact points"
|
||||||
</Stack>
|
tooltip="Refresh contact points list"
|
||||||
))}
|
className={cx(styles.refreshButton, {
|
||||||
|
[styles.loading]: loaderSpinning || isLoading,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</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`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
@ -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(
|
||||||
<AmRootRouteForm
|
<AlertmanagerProvider accessType="instance">
|
||||||
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
<AmRootRouteForm
|
||||||
actionButtons={<Button type="submit">Update default policy</Button>}
|
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||||
onSubmit={onSubmit}
|
actionButtons={<Button type="submit">Update default policy</Button>}
|
||||||
receivers={receivers}
|
onSubmit={onSubmit}
|
||||||
route={route}
|
route={route}
|
||||||
/>
|
/>
|
||||||
|
</AlertmanagerProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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];
|
||||||
|
@ -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';
|
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',
|
||||||
|
|
||||||
|
@ -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} />
|
||||||
|
@ -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?.value?.name);
|
||||||
onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
|
onSelectContactPoint(value?.value);
|
||||||
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.
|
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}>
|
<LinkToContactPoints />
|
||||||
<IconButton
|
</Stack>
|
||||||
name="sync"
|
|
||||||
onClick={onClickRefresh}
|
|
||||||
aria-label="Refresh contact points"
|
|
||||||
tooltip="Refresh contact points list"
|
|
||||||
className={cx(styles.refreshButton, {
|
|
||||||
[styles.loading]: loadingContactPoints,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<LinkToContactPoints />
|
|
||||||
</div>
|
|
||||||
</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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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"'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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
@ -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;
|
||||||
|
@ -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>;
|
||||||
|
@ -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[] => {
|
||||||
|
@ -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...",
|
||||||
|
@ -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ģ...",
|
||||||
|
Loading…
Reference in New Issue
Block a user