mirror of https://github.com/grafana/grafana
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 valuespull/65102/head
parent
cc5211119b
commit
d8e32cc929
@ -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 } |
||||
); |
||||
} |
@ -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 } |
||||
); |
||||
} |
@ -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'; |
@ -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', |
||||
}; |
Loading…
Reference in new issue