Alerting: Prometheus-compatible Alertmanager timings editor (#64526) (#65124)

* 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:
Konrad Lalik
2023-03-22 13:26:14 +01:00
committed by GitHub
parent 950b15d0aa
commit 721605af13
15 changed files with 471 additions and 310 deletions
@@ -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}>
@@ -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,
});
}
@@ -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);