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
pull/65102/head
Konrad Lalik 2 years ago committed by GitHub
parent cc5211119b
commit d8e32cc929
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      public/app/features/alerting/unified/TODO.md
  2. 131
      public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.test.tsx
  3. 166
      public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx
  4. 127
      public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.test.tsx
  5. 131
      public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx
  6. 1
      public/app/features/alerting/unified/components/notification-policies/Filters.tsx
  7. 3
      public/app/features/alerting/unified/components/notification-policies/Modals.tsx
  8. 22
      public/app/features/alerting/unified/components/notification-policies/Policy.tsx
  9. 68
      public/app/features/alerting/unified/components/notification-policies/PromDurationDocs.tsx
  10. 25
      public/app/features/alerting/unified/components/notification-policies/PromDurationInput.tsx
  11. 8
      public/app/features/alerting/unified/components/notification-policies/formStyles.ts
  12. 11
      public/app/features/alerting/unified/components/notification-policies/timingOptions.ts
  13. 3
      public/app/features/alerting/unified/types/amroutes.ts
  14. 61
      public/app/features/alerting/unified/utils/amroutes.ts
  15. 5
      public/app/features/alerting/unified/utils/time.ts

@ -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…
Cancel
Save