* 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
(cherry picked from commit d8e32cc929)
* Adjust code and tests to v.9.4 codebase
* Remove unused code, remove TODO item
This commit is contained in:
@@ -302,9 +302,9 @@ describe('AmRoutes', () => {
|
||||
// configure timing intervals
|
||||
await userEvent.click(byText('Timing options').get(rootRouteContainer));
|
||||
|
||||
await updateTiming(ui.groupWaitContainer.get(), '1', 'Minutes');
|
||||
await updateTiming(ui.groupIntervalContainer.get(), '4', 'Minutes');
|
||||
await updateTiming(ui.groupRepeatContainer.get(), '5', 'Hours');
|
||||
await updateTiming(ui.groupWaitContainer.get(), '1m');
|
||||
await updateTiming(ui.groupIntervalContainer.get(), '4m');
|
||||
await updateTiming(ui.groupRepeatContainer.get(), '5h');
|
||||
|
||||
//save
|
||||
await userEvent.click(ui.saveButton.get(rootRouteContainer));
|
||||
@@ -728,11 +728,9 @@ const clickSelectOption = async (selectElement: HTMLElement, optionText: string)
|
||||
await selectOptionInTest(selectElement, optionText);
|
||||
};
|
||||
|
||||
const updateTiming = async (selectElement: HTMLElement, value: string, timeUnit: string): Promise<void> => {
|
||||
const input = byRole('textbox').get(selectElement);
|
||||
const select = byRole('combobox').get(selectElement);
|
||||
const updateTiming = async (timingInputContainer: HTMLElement, value: string): Promise<void> => {
|
||||
const input = byRole('textbox').get(timingInputContainer);
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, value);
|
||||
await userEvent.click(select);
|
||||
await selectOptionInTest(selectElement, timeUnit);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { noop } from 'lodash';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import { amRouteToFormAmRoute } from '../../utils/amroutes';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
|
||||
|
||||
import { AmRootRouteForm } from './AmRootRouteForm';
|
||||
|
||||
const ui = {
|
||||
error: byRole('alert'),
|
||||
timingOptionsBtn: byRole('button', { name: /Timing options/ }),
|
||||
submitBtn: byRole('button', { name: /Save/ }),
|
||||
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('AmRootRouteForm', function () {
|
||||
describe('Timing options', function () {
|
||||
it('should render prometheus duration strings in form inputs', async function () {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderRouteForm({
|
||||
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(
|
||||
{
|
||||
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(
|
||||
{
|
||||
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: Route,
|
||||
receivers: AmRouteReceiver[] = [],
|
||||
onSubmit: (route: Partial<FormAmRoute>) => void = noop
|
||||
) {
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<Provider store={configureStore()}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
const [formAmRoute] = amRouteToFormAmRoute(route);
|
||||
|
||||
render(
|
||||
<AmRootRouteForm
|
||||
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||
onSave={onSubmit}
|
||||
receivers={receivers}
|
||||
routes={formAmRoute}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
{ wrapper: Wrapper }
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import React, { FC, useState } from 'react';
|
||||
|
||||
import { Button, Collapse, Field, Form, Input, InputControl, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Collapse, Field, Form, InputControl, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import {
|
||||
commonGroupByOptions,
|
||||
mapMultiSelectValueToStrings,
|
||||
mapSelectValueToString,
|
||||
optionalPositiveInteger,
|
||||
stringToSelectableValue,
|
||||
promDurationValidator,
|
||||
stringsToSelectableValues,
|
||||
commonGroupByOptions,
|
||||
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;
|
||||
@@ -39,7 +39,7 @@ export const AmRootRouteForm: FC<AmRootRouteFormProps> = ({
|
||||
|
||||
return (
|
||||
<Form defaultValues={{ ...routes, overrideTimings: true, overrideGrouping: true }} onSubmit={onSave}>
|
||||
{({ control, errors, setValue }) => (
|
||||
{({ register, control, errors, setValue }) => (
|
||||
<>
|
||||
<Field label="Default contact point" invalid={!!errors.receiver} error={errors.receiver?.message}>
|
||||
<>
|
||||
@@ -102,112 +102,50 @@ export const AmRootRouteForm: FC<AmRootRouteFormProps> = ({
|
||||
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}>
|
||||
<Button type="submit">Save</Button>
|
||||
|
||||
@@ -15,13 +15,9 @@ export const AmRootRouteRead: FC<AmRootRouteReadProps> = ({ routes }) => {
|
||||
|
||||
const receiver = routes.receiver || '-';
|
||||
const groupBy = routes.groupBy.join(', ') || '-';
|
||||
const groupWait = routes.groupWaitValue ? `${routes.groupWaitValue}${routes.groupWaitValueType}` : '-';
|
||||
const groupInterval = routes.groupIntervalValue
|
||||
? `${routes.groupIntervalValue}${routes.groupIntervalValueType}`
|
||||
: '-';
|
||||
const repeatInterval = routes.repeatIntervalValue
|
||||
? `${routes.repeatIntervalValue}${routes.repeatIntervalValueType}`
|
||||
: '-';
|
||||
const groupWait = routes.groupWaitValue || '-';
|
||||
const groupInterval = routes.groupIntervalValue || '-';
|
||||
const repeatInterval = routes.repeatIntervalValue || '-';
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
|
||||
+127
@@ -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 { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import { amRouteToFormAmRoute } from '../../utils/amroutes';
|
||||
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
|
||||
|
||||
import { AmRoutesExpandedForm } from './AmRoutesExpandedForm';
|
||||
|
||||
const ui = {
|
||||
error: byRole('alert'),
|
||||
overrideTimingsCheckbox: byRole('checkbox', { name: /Override general timings/ }),
|
||||
submitBtn: byRole('button', { name: /Save 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('AmRoutesExpandedForm', function () {
|
||||
describe('Timing options', function () {
|
||||
it('should render prometheus duration strings in form inputs', async function () {
|
||||
renderRouteForm({
|
||||
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(
|
||||
{
|
||||
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(
|
||||
{
|
||||
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: Route,
|
||||
receivers: AmRouteReceiver[] = [],
|
||||
onSubmit: (route: Partial<FormAmRoute>) => void = noop
|
||||
) {
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<Provider store={configureStore()}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
const [formAmRoute] = amRouteToFormAmRoute(route);
|
||||
|
||||
render(<AmRoutesExpandedForm receivers={receivers} routes={formAmRoute} onSave={onSubmit} onCancel={noop} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
}
|
||||
+21
-104
@@ -1,4 +1,4 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import React, { FC, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -27,14 +27,14 @@ import {
|
||||
emptyArrayFieldMatcher,
|
||||
mapMultiSelectValueToStrings,
|
||||
mapSelectValueToString,
|
||||
optionalPositiveInteger,
|
||||
stringToSelectableValue,
|
||||
stringsToSelectableValues,
|
||||
commonGroupByOptions,
|
||||
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 {
|
||||
@@ -81,7 +81,6 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
{fields.length > 0 && (
|
||||
<div className={styles.matchersContainer}>
|
||||
{fields.map((field, index) => {
|
||||
const localPath = `object_matchers[${index}]`;
|
||||
return (
|
||||
<HorizontalGroup key={field.id} align="flex-start" height="auto">
|
||||
<Field
|
||||
@@ -90,7 +89,7 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
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"
|
||||
/>
|
||||
@@ -108,7 +107,7 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
)}
|
||||
defaultValue={field.operator}
|
||||
control={control}
|
||||
name={`${localPath}.operator` as const}
|
||||
name={`object_matchers.${index}.operator`}
|
||||
rules={{ required: { value: true, message: 'Required.' } }}
|
||||
/>
|
||||
</Field>
|
||||
@@ -118,7 +117,7 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
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"
|
||||
/>
|
||||
@@ -209,38 +208,11 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
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"
|
||||
@@ -248,38 +220,11 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
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"
|
||||
@@ -287,39 +232,11 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -33,13 +33,9 @@ export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({
|
||||
const gridStyles = useStyles2(getGridStyles);
|
||||
const permissions = getNotificationsPermissions(alertManagerSourceName);
|
||||
|
||||
const groupWait = routes.groupWaitValue ? `${routes.groupWaitValue}${routes.groupWaitValueType}` : '-';
|
||||
const groupInterval = routes.groupIntervalValue
|
||||
? `${routes.groupIntervalValue}${routes.groupIntervalValueType}`
|
||||
: '-';
|
||||
const repeatInterval = routes.repeatIntervalValue
|
||||
? `${routes.repeatIntervalValue}${routes.repeatIntervalValueType}`
|
||||
: '-';
|
||||
const groupWait = routes.groupWaitValue ?? '-';
|
||||
const groupInterval = routes.groupIntervalValue ?? '-';
|
||||
const repeatInterval = routes.repeatIntervalValue ?? '-';
|
||||
|
||||
const [subroutes, setSubroutes] = useState(routes.routes);
|
||||
const [isAddMode, setIsAddMode] = useState(false);
|
||||
|
||||
@@ -14,11 +14,8 @@ const defaultAmRoute: FormAmRoute = {
|
||||
groupBy: [],
|
||||
overrideTimings: false,
|
||||
groupWaitValue: '',
|
||||
groupWaitValueType: '',
|
||||
groupIntervalValue: '',
|
||||
groupIntervalValueType: '',
|
||||
repeatIntervalValue: '',
|
||||
repeatIntervalValueType: '',
|
||||
muteTimeIntervals: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
@@ -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 { isUndefined, omitBy } from 'lodash';
|
||||
import { Validate } from 'react-hook-form';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
@@ -9,9 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form';
|
||||
|
||||
import { matcherToMatcherField, parseMatcher } from './alertmanager';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||
import { parseInterval, timeOptions } from './time';
|
||||
|
||||
const defaultValueAndType: [string, string] = ['', ''];
|
||||
import { isValidPrometheusDuration } from './time';
|
||||
|
||||
const matchersToArrayFieldMatchers = (
|
||||
matchers: Record<string, string> | undefined,
|
||||
@@ -29,25 +26,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[] =>
|
||||
@@ -79,11 +57,8 @@ export const emptyRoute: FormAmRoute = {
|
||||
receiver: '',
|
||||
overrideTimings: false,
|
||||
groupWaitValue: '',
|
||||
groupWaitValueType: timeOptions[0].value,
|
||||
groupIntervalValue: '',
|
||||
groupIntervalValueType: timeOptions[0].value,
|
||||
repeatIntervalValue: '',
|
||||
repeatIntervalValueType: timeOptions[0].value,
|
||||
muteTimeIntervals: [],
|
||||
};
|
||||
|
||||
@@ -117,10 +92,6 @@ export const amRouteToFormAmRoute = (route: Route | undefined): [FormAmRoute, Re
|
||||
(matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] } as MatcherFieldValue)
|
||||
) ?? [];
|
||||
|
||||
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,
|
||||
@@ -133,13 +104,10 @@ export const amRouteToFormAmRoute = (route: Route | undefined): [FormAmRoute, Re
|
||||
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 ?? [],
|
||||
},
|
||||
@@ -154,28 +122,19 @@ export const formAmRouteToAmRoute = (
|
||||
): Route => {
|
||||
const existing: Route | undefined = id2ExistingRoute[formAmRoute.id];
|
||||
|
||||
const {
|
||||
overrideGrouping,
|
||||
groupBy,
|
||||
overrideTimings,
|
||||
groupWaitValue,
|
||||
groupWaitValueType,
|
||||
groupIntervalValue,
|
||||
groupIntervalValueType,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalValueType,
|
||||
} = formAmRoute;
|
||||
const { overrideGrouping, groupBy, overrideTimings, groupWaitValue, groupIntervalValue, repeatIntervalValue } =
|
||||
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 amRoute: Route = {
|
||||
...(existing ?? {}),
|
||||
@@ -235,10 +194,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);
|
||||
|
||||
Reference in New Issue
Block a user