mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Prometheus-compatible Alertmanager timings editor (#64526)
* Change Alertmanager timings editor * Update timing inputs for default policy editor * Switch prom duration inputs in notification policy form * Fix a11y issues * Fix validation * Add timings forms tests * Fix default policy form and add more tests * Add notification policy form tests * Add todo item * Remove unused code * Use default timings object to fill placeholder values
This commit is contained in:
parent
cc5211119b
commit
d8e32cc929
@ -18,6 +18,7 @@ If the item needs more rationale and you feel like a single sentence is inedequa
|
||||
|
||||
- Get rid of "+ Add new" in drop-downs : Let's see if is there a way we can make it work with `<Select allowCustomValue />`
|
||||
- There is a lot of overlap between `RuleActionButtons` and `RuleDetailsActionButtons`. As these components contain a lot of logic it would be nice to extract that logic into hoooks
|
||||
- Create a shared timings form that can be used in both `EditDefaultPolicyForm.tsx` and `EditNotificationPolicyForm.tsx`
|
||||
|
||||
## Testing
|
||||
|
||||
|
@ -0,0 +1,131 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { noop } from 'lodash';
|
||||
import React from 'react';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { Button } from '@grafana/ui';
|
||||
|
||||
import { TestProvider } from '../../../../../../test/helpers/TestProvider';
|
||||
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';
|
||||
|
||||
const ui = {
|
||||
error: byRole('alert'),
|
||||
timingOptionsBtn: byRole('button', { name: /Timing options/ }),
|
||||
submitBtn: byRole('button', { name: /Update default policy/ }),
|
||||
groupWaitInput: byRole('textbox', { name: /Group wait/ }),
|
||||
groupIntervalInput: byRole('textbox', { name: /Group interval/ }),
|
||||
repeatIntervalInput: byRole('textbox', { name: /Repeat interval/ }),
|
||||
};
|
||||
|
||||
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
|
||||
describe('EditDefaultPolicyForm', function () {
|
||||
describe('Timing options', function () {
|
||||
it('should render prometheus duration strings in form inputs', async function () {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderRouteForm({
|
||||
id: '0',
|
||||
group_wait: '1m30s',
|
||||
group_interval: '2d4h30m35s',
|
||||
repeat_interval: '1w2d6h',
|
||||
});
|
||||
|
||||
await user.click(ui.timingOptionsBtn.get());
|
||||
expect(ui.groupWaitInput.get()).toHaveValue('1m30s');
|
||||
expect(ui.groupIntervalInput.get()).toHaveValue('2d4h30m35s');
|
||||
expect(ui.repeatIntervalInput.get()).toHaveValue('1w2d6h');
|
||||
});
|
||||
it('should allow submitting valid prometheus duration strings', async function () {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const onSubmit = jest.fn();
|
||||
renderRouteForm(
|
||||
{
|
||||
id: '0',
|
||||
receiver: 'default',
|
||||
},
|
||||
[{ value: 'default', label: 'Default' }],
|
||||
onSubmit
|
||||
);
|
||||
|
||||
await user.click(ui.timingOptionsBtn.get());
|
||||
|
||||
await user.type(ui.groupWaitInput.get(), '5m25s');
|
||||
await user.type(ui.groupIntervalInput.get(), '35m40s');
|
||||
await user.type(ui.repeatIntervalInput.get(), '4h30m');
|
||||
|
||||
await user.click(ui.submitBtn.get());
|
||||
|
||||
expect(ui.error.queryAll()).toHaveLength(0);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining<Partial<FormAmRoute>>({
|
||||
groupWaitValue: '5m25s',
|
||||
groupIntervalValue: '35m40s',
|
||||
repeatIntervalValue: '4h30m',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow resetting existing timing options', async function () {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const onSubmit = jest.fn();
|
||||
renderRouteForm(
|
||||
{
|
||||
id: '0',
|
||||
receiver: 'default',
|
||||
group_wait: '1m30s',
|
||||
group_interval: '2d4h30m35s',
|
||||
repeat_interval: '1w2d6h',
|
||||
},
|
||||
[{ value: 'default', label: 'Default' }],
|
||||
onSubmit
|
||||
);
|
||||
|
||||
await user.click(ui.timingOptionsBtn.get());
|
||||
await user.clear(ui.groupWaitInput.get());
|
||||
await user.clear(ui.groupIntervalInput.get());
|
||||
await user.clear(ui.repeatIntervalInput.get());
|
||||
|
||||
await user.click(ui.submitBtn.get());
|
||||
|
||||
expect(ui.error.queryAll()).toHaveLength(0);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining<Partial<FormAmRoute>>({
|
||||
groupWaitValue: '',
|
||||
groupIntervalValue: '',
|
||||
repeatIntervalValue: '',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderRouteForm(
|
||||
route: RouteWithID,
|
||||
receivers: AmRouteReceiver[] = [],
|
||||
onSubmit: (route: Partial<FormAmRoute>) => void = noop
|
||||
) {
|
||||
render(
|
||||
<AmRootRouteForm
|
||||
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||
actionButtons={<Button type="submit">Update default policy</Button>}
|
||||
onSubmit={onSubmit}
|
||||
receivers={receivers}
|
||||
route={route}
|
||||
/>,
|
||||
{ wrapper: TestProvider }
|
||||
);
|
||||
}
|
@ -1,24 +1,24 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
|
||||
import { Collapse, Field, Form, Input, InputControl, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui';
|
||||
import { Collapse, Field, Form, InputControl, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui';
|
||||
import { RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import {
|
||||
amRouteToFormAmRoute,
|
||||
commonGroupByOptions,
|
||||
mapMultiSelectValueToStrings,
|
||||
mapSelectValueToString,
|
||||
optionalPositiveInteger,
|
||||
stringToSelectableValue,
|
||||
promDurationValidator,
|
||||
stringsToSelectableValues,
|
||||
commonGroupByOptions,
|
||||
amRouteToFormAmRoute,
|
||||
stringToSelectableValue,
|
||||
} from '../../utils/amroutes';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { timeOptions } from '../../utils/time';
|
||||
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
|
||||
|
||||
import { PromDurationInput } from './PromDurationInput';
|
||||
import { getFormStyles } from './formStyles';
|
||||
import { TIMING_OPTIONS_DEFAULTS } from './timingOptions';
|
||||
|
||||
export interface AmRootRouteFormProps {
|
||||
alertManagerSourceName: string;
|
||||
@ -43,7 +43,7 @@ export const AmRootRouteForm = ({
|
||||
|
||||
return (
|
||||
<Form defaultValues={{ ...defaultValues, overrideTimings: true, overrideGrouping: true }} onSubmit={onSubmit}>
|
||||
{({ control, errors, setValue }) => (
|
||||
{({ register, control, errors, setValue }) => (
|
||||
<>
|
||||
<Field label="Default contact point" invalid={!!errors.receiver} error={errors.receiver?.message}>
|
||||
<>
|
||||
@ -106,112 +106,50 @@ export const AmRootRouteForm = ({
|
||||
label="Timing options"
|
||||
onToggle={setIsTimingOptionsExpanded}
|
||||
>
|
||||
<Field
|
||||
label="Group wait"
|
||||
description="The waiting time until the initial notification is sent for a new group created by an incoming alert. Default 30 seconds."
|
||||
invalid={!!errors.groupWaitValue}
|
||||
error={errors.groupWaitValue?.message}
|
||||
data-testid="am-group-wait"
|
||||
>
|
||||
<>
|
||||
<div className={cx(styles.container, styles.timingContainer)}>
|
||||
<InputControl
|
||||
render={({ field, fieldState: { invalid } }) => (
|
||||
<Input {...field} className={styles.smallInput} invalid={invalid} placeholder={'30'} />
|
||||
)}
|
||||
control={control}
|
||||
name="groupWaitValue"
|
||||
rules={{
|
||||
validate: optionalPositiveInteger,
|
||||
}}
|
||||
/>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Select
|
||||
{...field}
|
||||
className={styles.input}
|
||||
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||
options={timeOptions}
|
||||
aria-label="Group wait type"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="groupWaitValueType"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Field>
|
||||
<Field
|
||||
label="Group interval"
|
||||
description="The waiting time to send a batch of new alerts for that group after the first notification was sent. Default 5 minutes."
|
||||
invalid={!!errors.groupIntervalValue}
|
||||
error={errors.groupIntervalValue?.message}
|
||||
data-testid="am-group-interval"
|
||||
>
|
||||
<>
|
||||
<div className={cx(styles.container, styles.timingContainer)}>
|
||||
<InputControl
|
||||
render={({ field, fieldState: { invalid } }) => (
|
||||
<Input {...field} className={styles.smallInput} invalid={invalid} placeholder={'5'} />
|
||||
)}
|
||||
control={control}
|
||||
name="groupIntervalValue"
|
||||
rules={{
|
||||
validate: optionalPositiveInteger,
|
||||
}}
|
||||
/>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Select
|
||||
{...field}
|
||||
className={styles.input}
|
||||
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||
options={timeOptions}
|
||||
aria-label="Group interval type"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="groupIntervalValueType"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Field>
|
||||
<Field
|
||||
label="Repeat interval"
|
||||
description="The waiting time to resend an alert after they have successfully been sent. Default 4 hours."
|
||||
invalid={!!errors.repeatIntervalValue}
|
||||
error={errors.repeatIntervalValue?.message}
|
||||
data-testid="am-repeat-interval"
|
||||
>
|
||||
<>
|
||||
<div className={cx(styles.container, styles.timingContainer)}>
|
||||
<InputControl
|
||||
render={({ field, fieldState: { invalid } }) => (
|
||||
<Input {...field} className={styles.smallInput} invalid={invalid} placeholder="4" />
|
||||
)}
|
||||
control={control}
|
||||
name="repeatIntervalValue"
|
||||
rules={{
|
||||
validate: optionalPositiveInteger,
|
||||
}}
|
||||
/>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Select
|
||||
{...field}
|
||||
className={styles.input}
|
||||
menuPlacement="top"
|
||||
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||
options={timeOptions}
|
||||
aria-label="Repeat interval type"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="repeatIntervalValueType"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Field>
|
||||
<div className={styles.timingFormContainer}>
|
||||
<Field
|
||||
label="Group wait"
|
||||
description="The waiting time until the initial notification is sent for a new group created by an incoming alert. Default 30 seconds."
|
||||
invalid={!!errors.groupWaitValue}
|
||||
error={errors.groupWaitValue?.message}
|
||||
data-testid="am-group-wait"
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('groupWaitValue', { validate: promDurationValidator })}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.group_wait}
|
||||
className={styles.promDurationInput}
|
||||
aria-label="Group wait"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Group interval"
|
||||
description="The waiting time to send a batch of new alerts for that group after the first notification was sent. Default 5 minutes."
|
||||
invalid={!!errors.groupIntervalValue}
|
||||
error={errors.groupIntervalValue?.message}
|
||||
data-testid="am-group-interval"
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('groupIntervalValue', { validate: promDurationValidator })}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.group_interval}
|
||||
className={styles.promDurationInput}
|
||||
aria-label="Group interval"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Repeat interval"
|
||||
description="The waiting time to resend an alert after they have successfully been sent. Default 4 hours."
|
||||
invalid={!!errors.repeatIntervalValue}
|
||||
error={errors.repeatIntervalValue?.message}
|
||||
data-testid="am-repeat-interval"
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('repeatIntervalValue', { validate: promDurationValidator })}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.repeat_interval}
|
||||
className={styles.promDurationInput}
|
||||
aria-label="Repeat interval"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Collapse>
|
||||
<div className={styles.container}>{actionButtons}</div>
|
||||
</>
|
||||
|
@ -0,0 +1,127 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { noop } from 'lodash';
|
||||
import React from 'react';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { Button } from '@grafana/ui';
|
||||
|
||||
import { TestProvider } from '../../../../../../test/helpers/TestProvider';
|
||||
import { RouteWithID } from '../../../../../plugins/datasource/alertmanager/types';
|
||||
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
|
||||
|
||||
import { AmRoutesExpandedForm } from './EditNotificationPolicyForm';
|
||||
|
||||
const ui = {
|
||||
error: byRole('alert'),
|
||||
overrideTimingsCheckbox: byRole('checkbox', { name: /Override general timings/ }),
|
||||
submitBtn: byRole('button', { name: /Update default policy/ }),
|
||||
groupWaitInput: byRole('textbox', { name: /Group wait/ }),
|
||||
groupIntervalInput: byRole('textbox', { name: /Group interval/ }),
|
||||
repeatIntervalInput: byRole('textbox', { name: /Repeat interval/ }),
|
||||
};
|
||||
|
||||
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
|
||||
describe('EditNotificationPolicyForm', function () {
|
||||
describe('Timing options', function () {
|
||||
it('should render prometheus duration strings in form inputs', async function () {
|
||||
renderRouteForm({
|
||||
id: '1',
|
||||
group_wait: '1m30s',
|
||||
group_interval: '2d4h30m35s',
|
||||
repeat_interval: '1w2d6h',
|
||||
});
|
||||
|
||||
expect(ui.overrideTimingsCheckbox.get()).toBeChecked();
|
||||
expect(ui.groupWaitInput.get()).toHaveValue('1m30s');
|
||||
expect(ui.groupIntervalInput.get()).toHaveValue('2d4h30m35s');
|
||||
expect(ui.repeatIntervalInput.get()).toHaveValue('1w2d6h');
|
||||
});
|
||||
|
||||
it('should allow submitting valid prometheus duration strings', async function () {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const onSubmit = jest.fn();
|
||||
renderRouteForm(
|
||||
{
|
||||
id: '1',
|
||||
receiver: 'default',
|
||||
},
|
||||
[{ value: 'default', label: 'Default' }],
|
||||
onSubmit
|
||||
);
|
||||
|
||||
await user.click(ui.overrideTimingsCheckbox.get());
|
||||
|
||||
await user.type(ui.groupWaitInput.get(), '5m25s');
|
||||
await user.type(ui.groupIntervalInput.get(), '35m40s');
|
||||
await user.type(ui.repeatIntervalInput.get(), '4h30m');
|
||||
|
||||
await user.click(ui.submitBtn.get());
|
||||
|
||||
expect(ui.error.queryAll()).toHaveLength(0);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining<Partial<FormAmRoute>>({
|
||||
groupWaitValue: '5m25s',
|
||||
groupIntervalValue: '35m40s',
|
||||
repeatIntervalValue: '4h30m',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow resetting existing timing options', async function () {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const onSubmit = jest.fn();
|
||||
renderRouteForm(
|
||||
{
|
||||
id: '0',
|
||||
receiver: 'default',
|
||||
group_wait: '1m30s',
|
||||
group_interval: '2d4h30m35s',
|
||||
repeat_interval: '1w2d6h',
|
||||
},
|
||||
[{ value: 'default', label: 'Default' }],
|
||||
onSubmit
|
||||
);
|
||||
|
||||
await user.clear(ui.groupWaitInput.get());
|
||||
await user.clear(ui.groupIntervalInput.get());
|
||||
await user.clear(ui.repeatIntervalInput.get());
|
||||
|
||||
await user.click(ui.submitBtn.get());
|
||||
|
||||
expect(ui.error.queryAll()).toHaveLength(0);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining<Partial<FormAmRoute>>({
|
||||
groupWaitValue: '',
|
||||
groupIntervalValue: '',
|
||||
repeatIntervalValue: '',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderRouteForm(
|
||||
route: RouteWithID,
|
||||
receivers: AmRouteReceiver[] = [],
|
||||
onSubmit: (route: Partial<FormAmRoute>) => void = noop
|
||||
) {
|
||||
render(
|
||||
<AmRoutesExpandedForm
|
||||
actionButtons={<Button type="submit">Update default policy</Button>}
|
||||
onSubmit={onSubmit}
|
||||
receivers={receivers}
|
||||
route={route}
|
||||
/>,
|
||||
{ wrapper: TestProvider }
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@ -27,15 +27,15 @@ import {
|
||||
emptyArrayFieldMatcher,
|
||||
mapMultiSelectValueToStrings,
|
||||
mapSelectValueToString,
|
||||
optionalPositiveInteger,
|
||||
stringToSelectableValue,
|
||||
stringsToSelectableValues,
|
||||
commonGroupByOptions,
|
||||
amRouteToFormAmRoute,
|
||||
promDurationValidator,
|
||||
} from '../../utils/amroutes';
|
||||
import { timeOptions } from '../../utils/time';
|
||||
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
|
||||
|
||||
import { PromDurationInput } from './PromDurationInput';
|
||||
import { getFormStyles } from './formStyles';
|
||||
|
||||
export interface AmRoutesExpandedFormProps {
|
||||
@ -57,6 +57,7 @@ export const AmRoutesExpandedForm = ({
|
||||
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);
|
||||
|
||||
@ -65,9 +66,7 @@ export const AmRoutesExpandedForm = ({
|
||||
...defaults,
|
||||
};
|
||||
|
||||
const emptyMatcher = [{ name: '', operator: MatcherOperator.equal, value: '' }];
|
||||
|
||||
const defaultValues: FormAmRoute = {
|
||||
const defaultValues: Omit<FormAmRoute, 'routes'> = {
|
||||
...formAmRoute,
|
||||
// if we're adding a new route, show at least one empty matcher
|
||||
object_matchers: route ? formAmRoute.object_matchers : emptyMatcher,
|
||||
@ -77,7 +76,6 @@ export const AmRoutesExpandedForm = ({
|
||||
<Form defaultValues={defaultValues} onSubmit={onSubmit} maxWidth="none">
|
||||
{({ control, register, errors, setValue, watch }) => (
|
||||
<>
|
||||
{/* @ts-ignore-check: react-hook-form made me do this */}
|
||||
<input type="hidden" {...register('id')} />
|
||||
{/* @ts-ignore-check: react-hook-form made me do this */}
|
||||
<FieldArray name="object_matchers" control={control}>
|
||||
@ -96,7 +94,6 @@ export const AmRoutesExpandedForm = ({
|
||||
{fields.length > 0 && (
|
||||
<div className={styles.matchersContainer}>
|
||||
{fields.map((field, index) => {
|
||||
const localPath = `object_matchers[${index}]`;
|
||||
return (
|
||||
<Stack direction="row" key={field.id} alignItems="center">
|
||||
<Field
|
||||
@ -105,7 +102,7 @@ export const AmRoutesExpandedForm = ({
|
||||
error={errors.object_matchers?.[index]?.name?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`${localPath}.name`, { required: 'Field is required' })}
|
||||
{...register(`object_matchers.${index}.name`, { required: 'Field is required' })}
|
||||
defaultValue={field.name}
|
||||
placeholder="label"
|
||||
autoFocus
|
||||
@ -124,7 +121,7 @@ export const AmRoutesExpandedForm = ({
|
||||
)}
|
||||
defaultValue={field.operator}
|
||||
control={control}
|
||||
name={`${localPath}.operator` as const}
|
||||
name={`object_matchers.${index}.operator`}
|
||||
rules={{ required: { value: true, message: 'Required.' } }}
|
||||
/>
|
||||
</Field>
|
||||
@ -134,7 +131,7 @@ export const AmRoutesExpandedForm = ({
|
||||
error={errors.object_matchers?.[index]?.value?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`${localPath}.value`, { required: 'Field is required' })}
|
||||
{...register(`object_matchers.${index}.value`, { required: 'Field is required' })}
|
||||
defaultValue={field.value}
|
||||
placeholder="value"
|
||||
/>
|
||||
@ -225,38 +222,11 @@ export const AmRoutesExpandedForm = ({
|
||||
invalid={!!errors.groupWaitValue}
|
||||
error={errors.groupWaitValue?.message}
|
||||
>
|
||||
<>
|
||||
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||
<InputControl
|
||||
render={({ field, fieldState: { invalid } }) => (
|
||||
<Input
|
||||
{...field}
|
||||
className={formStyles.smallInput}
|
||||
invalid={invalid}
|
||||
aria-label="Group wait value"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="groupWaitValue"
|
||||
rules={{
|
||||
validate: optionalPositiveInteger,
|
||||
}}
|
||||
/>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Select
|
||||
{...field}
|
||||
className={formStyles.input}
|
||||
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||
options={timeOptions}
|
||||
aria-label="Group wait type"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="groupWaitValueType"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<PromDurationInput
|
||||
{...register('groupWaitValue', { validate: promDurationValidator })}
|
||||
aria-label="Group wait value"
|
||||
className={formStyles.promDurationInput}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Group interval"
|
||||
@ -264,38 +234,11 @@ export const AmRoutesExpandedForm = ({
|
||||
invalid={!!errors.groupIntervalValue}
|
||||
error={errors.groupIntervalValue?.message}
|
||||
>
|
||||
<>
|
||||
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||
<InputControl
|
||||
render={({ field, fieldState: { invalid } }) => (
|
||||
<Input
|
||||
{...field}
|
||||
className={formStyles.smallInput}
|
||||
invalid={invalid}
|
||||
aria-label="Group interval value"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="groupIntervalValue"
|
||||
rules={{
|
||||
validate: optionalPositiveInteger,
|
||||
}}
|
||||
/>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Select
|
||||
{...field}
|
||||
className={formStyles.input}
|
||||
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||
options={timeOptions}
|
||||
aria-label="Group interval type"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="groupIntervalValueType"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<PromDurationInput
|
||||
{...register('groupIntervalValue', { validate: promDurationValidator })}
|
||||
aria-label="Group interval value"
|
||||
className={formStyles.promDurationInput}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Repeat interval"
|
||||
@ -303,39 +246,11 @@ export const AmRoutesExpandedForm = ({
|
||||
invalid={!!errors.repeatIntervalValue}
|
||||
error={errors.repeatIntervalValue?.message}
|
||||
>
|
||||
<>
|
||||
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||
<InputControl
|
||||
render={({ field, fieldState: { invalid } }) => (
|
||||
<Input
|
||||
{...field}
|
||||
className={formStyles.smallInput}
|
||||
invalid={invalid}
|
||||
aria-label="Repeat interval value"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="repeatIntervalValue"
|
||||
rules={{
|
||||
validate: optionalPositiveInteger,
|
||||
}}
|
||||
/>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Select
|
||||
{...field}
|
||||
className={formStyles.input}
|
||||
menuPlacement="top"
|
||||
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||
options={timeOptions}
|
||||
aria-label="Repeat interval type"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="repeatIntervalValueType"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<PromDurationInput
|
||||
{...register('repeatIntervalValue', { validate: promDurationValidator })}
|
||||
aria-label="Repeat interval value"
|
||||
className={formStyles.promDurationInput}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
@ -89,6 +89,7 @@ const NotificationPoliciesFilter = ({
|
||||
<Field label="Search by contact point" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
id="receiver"
|
||||
aria-label="Search by contact point"
|
||||
value={selectedContactPoint}
|
||||
options={receiverOptions}
|
||||
onChange={(option) => {
|
||||
|
@ -21,6 +21,7 @@ import { AmRoutesExpandedForm } from './EditNotificationPolicyForm';
|
||||
import { Matchers } from './Matchers';
|
||||
|
||||
type ModalHook<T = undefined> = [JSX.Element, (item: T) => void, () => void];
|
||||
type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void];
|
||||
|
||||
const useAddPolicyModal = (
|
||||
receivers: Receiver[] = [],
|
||||
@ -81,7 +82,7 @@ const useEditPolicyModal = (
|
||||
receivers: Receiver[],
|
||||
handleSave: (route: Partial<FormAmRoute>) => void,
|
||||
loading: boolean
|
||||
): ModalHook<RouteWithID> => {
|
||||
): EditModalHook => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isDefaultPolicy, setIsDefaultPolicy] = useState(false);
|
||||
const [route, setRoute] = useState<RouteWithID>();
|
||||
|
@ -30,12 +30,7 @@ import { Spacer } from '../Spacer';
|
||||
import { Strong } from '../Strong';
|
||||
|
||||
import { Matchers } from './Matchers';
|
||||
|
||||
type TimingOptions = {
|
||||
group_wait?: string;
|
||||
group_interval?: string;
|
||||
repeat_interval?: string;
|
||||
};
|
||||
import { TimingOptions, TIMING_OPTIONS_DEFAULTS } from './timingOptions';
|
||||
|
||||
type InhertitableProperties = Pick<
|
||||
Route,
|
||||
@ -184,7 +179,14 @@ const Policy: FC<PolicyComponentProps> = ({
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" size="sm" icon="ellipsis-h" type="button" data-testid="more-actions" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="ellipsis-h"
|
||||
type="button"
|
||||
aria-label="more-actions"
|
||||
data-testid="more-actions"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
)}
|
||||
@ -417,12 +419,6 @@ const MuteTimings: FC<{ timings: string[]; alertManagerSourceName: string }> = (
|
||||
);
|
||||
};
|
||||
|
||||
const TIMING_OPTIONS_DEFAULTS = {
|
||||
group_wait: '30s',
|
||||
group_interval: '5m',
|
||||
repeat_interval: '4h',
|
||||
};
|
||||
|
||||
const TimingOptionsMeta: FC<{ timingOptions: TimingOptions }> = ({ timingOptions }) => {
|
||||
const groupWait = timingOptions.group_wait ?? TIMING_OPTIONS_DEFAULTS.group_wait;
|
||||
const groupInterval = timingOptions.group_interval ?? TIMING_OPTIONS_DEFAULTS.group_interval;
|
||||
|
@ -0,0 +1,68 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { TimeOptions } from '../../types/time';
|
||||
|
||||
export function PromDurationDocs() {
|
||||
const styles = useStyles2(getPromDurationStyles);
|
||||
return (
|
||||
<div>
|
||||
Prometheus duration format consist of a number followed by a time unit.
|
||||
<br />
|
||||
Different units can be combined for more granularity.
|
||||
<hr />
|
||||
<div className={styles.list}>
|
||||
<div className={styles.header}>
|
||||
<div>Symbol</div>
|
||||
<div>Time unit</div>
|
||||
<div>Example</div>
|
||||
</div>
|
||||
<PromDurationDocsTimeUnit unit={TimeOptions.seconds} name="seconds" example="20s" />
|
||||
<PromDurationDocsTimeUnit unit={TimeOptions.minutes} name="minutes" example="10m" />
|
||||
<PromDurationDocsTimeUnit unit={TimeOptions.hours} name="hours" example="4h" />
|
||||
<PromDurationDocsTimeUnit unit={TimeOptions.days} name="days" example="3d" />
|
||||
<PromDurationDocsTimeUnit unit={TimeOptions.weeks} name="weeks" example="2w" />
|
||||
<div className={styles.examples}>
|
||||
<div>Multiple units combined</div>
|
||||
<code>1m30s, 2h30m20s, 1w2d</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromDurationDocsTimeUnit({ unit, name, example }: { unit: TimeOptions; name: string; example: string }) {
|
||||
const styles = useStyles2(getPromDurationStyles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.unit}>{unit}</div>
|
||||
<div>{name}</div>
|
||||
<code>{example}</code>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getPromDurationStyles = (theme: GrafanaTheme2) => ({
|
||||
unit: css`
|
||||
font-weight: ${theme.typography.fontWeightBold};
|
||||
`,
|
||||
list: css`
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr 2fr;
|
||||
gap: ${theme.spacing(1, 3)};
|
||||
`,
|
||||
header: css`
|
||||
display: contents;
|
||||
font-weight: ${theme.typography.fontWeightBold};
|
||||
`,
|
||||
examples: css`
|
||||
display: contents;
|
||||
& > div {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
`,
|
||||
});
|
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, Input } from '@grafana/ui';
|
||||
|
||||
import { HoverCard } from '../HoverCard';
|
||||
|
||||
import { PromDurationDocs } from './PromDurationDocs';
|
||||
|
||||
export const PromDurationInput = React.forwardRef<HTMLInputElement, React.ComponentProps<typeof Input>>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<Input
|
||||
suffix={
|
||||
<HoverCard content={<PromDurationDocs />} disabled={false}>
|
||||
<Icon name="info-circle" size="lg" />
|
||||
</HoverCard>
|
||||
}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PromDurationInput.displayName = 'PromDurationInput';
|
@ -16,11 +16,11 @@ export const getFormStyles = (theme: GrafanaTheme2) => {
|
||||
input: css`
|
||||
flex: 1;
|
||||
`,
|
||||
timingContainer: css`
|
||||
max-width: ${theme.spacing(33)};
|
||||
promDurationInput: css`
|
||||
max-width: ${theme.spacing(32)};
|
||||
`,
|
||||
smallInput: css`
|
||||
width: ${theme.spacing(6.5)};
|
||||
timingFormContainer: css`
|
||||
padding: ${theme.spacing(1)};
|
||||
`,
|
||||
linkText: css`
|
||||
text-decoration: underline;
|
||||
|
@ -0,0 +1,11 @@
|
||||
export type TimingOptions = {
|
||||
group_wait?: string;
|
||||
group_interval?: string;
|
||||
repeat_interval?: string;
|
||||
};
|
||||
|
||||
export const TIMING_OPTIONS_DEFAULTS: Required<TimingOptions> = {
|
||||
group_wait: '30s',
|
||||
group_interval: '5m',
|
||||
repeat_interval: '4h',
|
||||
};
|
@ -9,11 +9,8 @@ export interface FormAmRoute {
|
||||
groupBy: string[];
|
||||
overrideTimings: boolean;
|
||||
groupWaitValue: string;
|
||||
groupWaitValueType: string;
|
||||
groupIntervalValue: string;
|
||||
groupIntervalValueType: string;
|
||||
repeatIntervalValue: string;
|
||||
repeatIntervalValueType: string;
|
||||
muteTimeIntervals: string[];
|
||||
routes: FormAmRoute[];
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { uniqueId } from 'lodash';
|
||||
import { Validate } from 'react-hook-form';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
@ -10,9 +9,7 @@ import { MatcherFieldValue } from '../types/silence-form';
|
||||
import { matcherToMatcherField, parseMatcher } from './alertmanager';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||
import { findExistingRoute } from './routeTree';
|
||||
import { parseInterval, timeOptions } from './time';
|
||||
|
||||
const defaultValueAndType: [string, string] = ['', ''];
|
||||
import { isValidPrometheusDuration } from './time';
|
||||
|
||||
const matchersToArrayFieldMatchers = (
|
||||
matchers: Record<string, string> | undefined,
|
||||
@ -30,25 +27,6 @@ const matchersToArrayFieldMatchers = (
|
||||
[] as MatcherFieldValue[]
|
||||
);
|
||||
|
||||
const intervalToValueAndType = (
|
||||
strValue: string | undefined,
|
||||
defaultValue?: typeof defaultValueAndType
|
||||
): [string, string] => {
|
||||
if (!strValue) {
|
||||
return defaultValue ?? defaultValueAndType;
|
||||
}
|
||||
|
||||
const [value, valueType] = strValue ? parseInterval(strValue) : [undefined, undefined];
|
||||
|
||||
const timeOption = timeOptions.find((opt) => opt.value === valueType);
|
||||
|
||||
if (!value || !timeOption) {
|
||||
return defaultValueAndType;
|
||||
}
|
||||
|
||||
return [String(value), timeOption.value];
|
||||
};
|
||||
|
||||
const selectableValueToString = (selectableValue: SelectableValue<string>): string => selectableValue.value!;
|
||||
|
||||
const selectableValuesToStrings = (arr: Array<SelectableValue<string>> | undefined): string[] =>
|
||||
@ -80,11 +58,8 @@ export const emptyRoute: FormAmRoute = {
|
||||
receiver: '',
|
||||
overrideTimings: false,
|
||||
groupWaitValue: '',
|
||||
groupWaitValueType: timeOptions[0].value,
|
||||
groupIntervalValue: '',
|
||||
groupIntervalValueType: timeOptions[0].value,
|
||||
repeatIntervalValue: '',
|
||||
repeatIntervalValueType: timeOptions[0].value,
|
||||
muteTimeIntervals: [],
|
||||
};
|
||||
|
||||
@ -168,10 +143,6 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo
|
||||
route.object_matchers?.map((matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] })) ?? [];
|
||||
const matchers = route.matchers?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) ?? [];
|
||||
|
||||
const [groupWaitValue, groupWaitValueType] = intervalToValueAndType(route.group_wait, ['', 's']);
|
||||
const [groupIntervalValue, groupIntervalValueType] = intervalToValueAndType(route.group_interval, ['', 'm']);
|
||||
const [repeatIntervalValue, repeatIntervalValueType] = intervalToValueAndType(route.repeat_interval, ['', 'h']);
|
||||
|
||||
return {
|
||||
id,
|
||||
// Frontend migration to use object_matchers instead of matchers, match, and match_re
|
||||
@ -185,13 +156,10 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo
|
||||
receiver: route.receiver ?? '',
|
||||
overrideGrouping: Array.isArray(route.group_by) && route.group_by.length !== 0,
|
||||
groupBy: route.group_by ?? [],
|
||||
overrideTimings: [groupWaitValue, groupIntervalValue, repeatIntervalValue].some(Boolean),
|
||||
groupWaitValue,
|
||||
groupWaitValueType,
|
||||
groupIntervalValue,
|
||||
groupIntervalValueType,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalValueType,
|
||||
overrideTimings: [route.group_wait, route.group_interval, route.repeat_interval].some(Boolean),
|
||||
groupWaitValue: route.group_wait ?? '',
|
||||
groupIntervalValue: route.group_interval ?? '',
|
||||
repeatIntervalValue: route.repeat_interval ?? '',
|
||||
routes: formRoutes,
|
||||
muteTimeIntervals: route.mute_time_intervals ?? [],
|
||||
};
|
||||
@ -210,24 +178,21 @@ export const formAmRouteToAmRoute = (
|
||||
groupBy,
|
||||
overrideTimings,
|
||||
groupWaitValue,
|
||||
groupWaitValueType,
|
||||
groupIntervalValue,
|
||||
groupIntervalValueType,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalValueType,
|
||||
receiver,
|
||||
} = formAmRoute;
|
||||
|
||||
const group_by = overrideGrouping && groupBy ? groupBy : [];
|
||||
|
||||
const overrideGroupWait = overrideTimings && groupWaitValue;
|
||||
const group_wait = overrideGroupWait ? `${groupWaitValue}${groupWaitValueType}` : undefined;
|
||||
const group_wait = overrideGroupWait ? groupWaitValue : undefined;
|
||||
|
||||
const overrideGroupInterval = overrideTimings && groupIntervalValue;
|
||||
const group_interval = overrideGroupInterval ? `${groupIntervalValue}${groupIntervalValueType}` : undefined;
|
||||
const group_interval = overrideGroupInterval ? groupIntervalValue : undefined;
|
||||
|
||||
const overrideRepeatInterval = overrideTimings && repeatIntervalValue;
|
||||
const repeat_interval = overrideRepeatInterval ? `${repeatIntervalValue}${repeatIntervalValueType}` : undefined;
|
||||
const repeat_interval = overrideRepeatInterval ? repeatIntervalValue : undefined;
|
||||
const object_matchers = formAmRoute.object_matchers
|
||||
?.filter((route) => route.name && route.value && route.operator)
|
||||
.map(({ name, operator, value }) => [name, operator, value] as ObjectMatcher);
|
||||
@ -300,10 +265,10 @@ export const mapMultiSelectValueToStrings = (
|
||||
return selectableValuesToStrings(selectableValues);
|
||||
};
|
||||
|
||||
export const optionalPositiveInteger: Validate<string> = (value) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
export function promDurationValidator(duration: string) {
|
||||
if (duration.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !/^\d+$/.test(value) ? 'Must be a positive integer.' : undefined;
|
||||
};
|
||||
return isValidPrometheusDuration(duration) || 'Invalid duration format. Must be {number}{time_unit}';
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { durationToMilliseconds, parseDuration } from '@grafana/data';
|
||||
import { describeInterval } from '@grafana/data/src/datetime/rangeutil';
|
||||
|
||||
import { TimeOptions } from '../types/time';
|
||||
@ -28,10 +27,6 @@ export const timeOptions = Object.entries(TimeOptions).map(([key, value]) => ({
|
||||
value: value,
|
||||
}));
|
||||
|
||||
export function parseDurationToMilliseconds(duration: string) {
|
||||
return durationToMilliseconds(parseDuration(duration));
|
||||
}
|
||||
|
||||
export function isValidPrometheusDuration(duration: string): boolean {
|
||||
try {
|
||||
parsePrometheusDuration(duration);
|
||||
|
Loading…
Reference in New Issue
Block a user