Skip to content

Commit 862dc3a

Browse files
merge upstream alerting_v2
2 parents 11078cf + b36ab75 commit 862dc3a

81 files changed

Lines changed: 3795 additions & 188 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/dev/storybook/aliases.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
export const storybookAliases = {
1111
ai_assistant: 'x-pack/platform/packages/shared/kbn-ai-assistant/.storybook',
12+
alerting_v2: 'x-pack/platform/plugins/shared/alerting_v2/.storybook',
1213
alerting_v2_rule_form:
1314
'x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/.storybook',
1415
apm: 'x-pack/solutions/observability/plugins/apm/.storybook',

x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
export { ConditionFieldGroup } from './condition_field_group';
99
export { RuleDetailsFieldGroup } from './rule_details_field_group';
1010
export { RuleExecutionFieldGroup } from './rule_execution_field_group';
11+
export { StateTransitionFieldGroup } from './state_transition_field_group';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { fireEvent, render, screen } from '@testing-library/react';
10+
import { StateTransitionFieldGroup } from './state_transition_field_group';
11+
import { createFormWrapper } from '../../test_utils';
12+
13+
describe('StateTransitionFieldGroup', () => {
14+
it('renders immediate mode by default when kind is "alert"', () => {
15+
render(<StateTransitionFieldGroup />, {
16+
wrapper: createFormWrapper({ kind: 'alert' }),
17+
});
18+
19+
expect(screen.getByText('Alert delay')).toBeInTheDocument();
20+
expect(screen.getByText('Immediate')).toBeInTheDocument();
21+
expect(screen.getByText('Breaches')).toBeInTheDocument();
22+
expect(screen.getByText('Duration')).toBeInTheDocument();
23+
expect(screen.getByTestId('stateTransitionImmediateDescription')).toBeInTheDocument();
24+
expect(screen.queryByTestId('stateTransitionCountInput')).not.toBeInTheDocument();
25+
expect(screen.queryByTestId('stateTransitionTimeframeNumberInput')).not.toBeInTheDocument();
26+
});
27+
28+
it('shows breaches input when breaches is selected', () => {
29+
render(<StateTransitionFieldGroup />, {
30+
wrapper: createFormWrapper({ kind: 'alert' }),
31+
});
32+
33+
fireEvent.click(screen.getByRole('button', { name: 'Breaches' }));
34+
35+
expect(screen.getByTestId('stateTransitionCountInput')).toBeInTheDocument();
36+
expect(screen.getByTestId('stateTransitionCountInput')).toHaveValue(2);
37+
expect(screen.queryByTestId('stateTransitionImmediateDescription')).not.toBeInTheDocument();
38+
expect(screen.queryByTestId('stateTransitionTimeframeNumberInput')).not.toBeInTheDocument();
39+
});
40+
41+
it('does not render when kind is "signal"', () => {
42+
render(<StateTransitionFieldGroup />, {
43+
wrapper: createFormWrapper({ kind: 'signal' }),
44+
});
45+
46+
expect(screen.queryByText('Alert delay')).not.toBeInTheDocument();
47+
});
48+
49+
it('shows immediate mode text when immediate is selected', () => {
50+
render(<StateTransitionFieldGroup />, {
51+
wrapper: createFormWrapper({ kind: 'alert' }),
52+
});
53+
54+
fireEvent.click(screen.getByRole('button', { name: 'Immediate' }));
55+
56+
expect(screen.getByTestId('stateTransitionImmediateDescription')).toBeInTheDocument();
57+
expect(screen.queryByTestId('stateTransitionCountInput')).not.toBeInTheDocument();
58+
expect(screen.queryByTestId('stateTransitionTimeframeNumberInput')).not.toBeInTheDocument();
59+
});
60+
61+
it('shows duration inputs when duration is selected', () => {
62+
render(<StateTransitionFieldGroup />, {
63+
wrapper: createFormWrapper({ kind: 'alert' }),
64+
});
65+
66+
fireEvent.click(screen.getByRole('button', { name: 'Duration' }));
67+
68+
expect(screen.getByTestId('stateTransitionTimeframeNumberInput')).toBeInTheDocument();
69+
expect(screen.getByTestId('stateTransitionTimeframeUnitInput')).toBeInTheDocument();
70+
expect(screen.getByTestId('stateTransitionTimeframeNumberInput')).toHaveValue(2);
71+
expect(screen.getByTestId('stateTransitionTimeframeUnitInput')).toHaveValue('m');
72+
expect(screen.queryByTestId('stateTransitionCountInput')).not.toBeInTheDocument();
73+
});
74+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { useCallback, useState } from 'react';
9+
import { EuiButtonGroup, EuiSpacer, EuiText } from '@elastic/eui';
10+
import { i18n } from '@kbn/i18n';
11+
import { useFormContext, useWatch } from 'react-hook-form';
12+
import type { FormValues } from '../types';
13+
import { FieldGroup } from './field_group';
14+
import { StateTransitionCountField } from '../fields/state_transition_count_field';
15+
import { StateTransitionTimeframeField } from '../fields/state_transition_timeframe_field';
16+
17+
type DelayMode = 'immediate' | 'breaches' | 'duration';
18+
19+
const MODE_OPTIONS = [
20+
{
21+
id: 'immediate' as const,
22+
label: i18n.translate('xpack.alertingV2.ruleForm.stateTransition.delayModeImmediate', {
23+
defaultMessage: 'Immediate',
24+
}),
25+
},
26+
{
27+
id: 'breaches' as const,
28+
label: i18n.translate('xpack.alertingV2.ruleForm.stateTransition.delayModeBreaches', {
29+
defaultMessage: 'Breaches',
30+
}),
31+
},
32+
{
33+
id: 'duration' as const,
34+
label: i18n.translate('xpack.alertingV2.ruleForm.stateTransition.delayModeDuration', {
35+
defaultMessage: 'Duration',
36+
}),
37+
},
38+
];
39+
40+
const DEFAULT_PENDING_COUNT = 2;
41+
const DEFAULT_PENDING_TIMEFRAME = '2m';
42+
43+
const deriveMode = (stateTransition?: {
44+
pendingTimeframe?: string;
45+
pendingCount?: number;
46+
}): DelayMode => {
47+
if (stateTransition?.pendingTimeframe != null) return 'duration';
48+
if (stateTransition?.pendingCount != null) return 'breaches';
49+
return 'immediate';
50+
};
51+
52+
export const StateTransitionFieldGroup: React.FC = () => {
53+
const { control, setValue } = useFormContext<FormValues>();
54+
const kind = useWatch({ control, name: 'kind' });
55+
const stateTransition = useWatch({ control, name: 'stateTransition' });
56+
const [selectedMode, setSelectedMode] = useState<DelayMode>(deriveMode(stateTransition));
57+
58+
const onModeChange = useCallback(
59+
(mode: string) => {
60+
switch (mode as DelayMode) {
61+
case 'immediate':
62+
setSelectedMode('immediate');
63+
setValue('stateTransition', undefined);
64+
break;
65+
case 'breaches':
66+
setSelectedMode('breaches');
67+
setValue(
68+
'stateTransition.pendingCount',
69+
stateTransition?.pendingCount ?? DEFAULT_PENDING_COUNT
70+
);
71+
setValue('stateTransition.pendingTimeframe', undefined);
72+
break;
73+
case 'duration':
74+
setSelectedMode('duration');
75+
setValue('stateTransition.pendingCount', undefined);
76+
setValue(
77+
'stateTransition.pendingTimeframe',
78+
stateTransition?.pendingTimeframe ?? DEFAULT_PENDING_TIMEFRAME
79+
);
80+
break;
81+
}
82+
},
83+
[setValue, stateTransition?.pendingCount, stateTransition?.pendingTimeframe]
84+
);
85+
86+
if (kind !== 'alert') {
87+
return null;
88+
}
89+
90+
return (
91+
<FieldGroup
92+
title={i18n.translate('xpack.alertingV2.ruleForm.stateTransition.title', {
93+
defaultMessage: 'Alert delay',
94+
})}
95+
>
96+
<EuiButtonGroup
97+
buttonSize="s"
98+
legend={i18n.translate('xpack.alertingV2.ruleForm.stateTransition.delayModeLegend', {
99+
defaultMessage: 'Alert delay mode',
100+
})}
101+
options={MODE_OPTIONS}
102+
idSelected={selectedMode}
103+
onChange={onModeChange}
104+
isFullWidth
105+
data-test-subj="stateTransitionDelayMode"
106+
/>
107+
<EuiSpacer size="s" />
108+
{selectedMode === 'immediate' && (
109+
<EuiText size="xs" color="subdued" data-test-subj="stateTransitionImmediateDescription">
110+
{i18n.translate('xpack.alertingV2.ruleForm.stateTransition.immediateDescription', {
111+
defaultMessage: 'No delay - Alerts on first breach',
112+
})}
113+
</EuiText>
114+
)}
115+
{selectedMode === 'breaches' && (
116+
<StateTransitionCountField
117+
prependLabel={i18n.translate(
118+
'xpack.alertingV2.ruleForm.stateTransition.inlineBreachesPrepend',
119+
{ defaultMessage: 'Consecutive breaches' }
120+
)}
121+
/>
122+
)}
123+
{selectedMode === 'duration' && (
124+
<StateTransitionTimeframeField
125+
numberPrependLabel={i18n.translate(
126+
'xpack.alertingV2.ruleForm.stateTransition.inlineDurationPrepend',
127+
{ defaultMessage: 'Active for' }
128+
)}
129+
/>
130+
)}
131+
</FieldGroup>
132+
);
133+
};

x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/description_field.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ export const DescriptionField: React.FC = () => {
6161
label={i18n.translate('xpack.alertingV2.ruleForm.descriptionLabel', {
6262
defaultMessage: 'Description',
6363
})}
64+
fullWidth
6465
isInvalid={!!error}
6566
error={error?.message}
66-
fullWidth
6767
>
6868
<EuiTextArea
6969
{...field}

x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/duration_input.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77

88
import React, { useMemo, useCallback, useState, useEffect } from 'react';
99
import { EuiFlexItem, EuiFormRow, EuiFlexGroup, EuiSelect, EuiFieldNumber } from '@elastic/eui';
10-
import { getDurationUnitValue, getDurationNumberInItsUnit } from '../../flyout/utils';
11-
import { getTimeOptions } from '../../flyout/utils';
12-
13-
const INTEGER_REGEX = /^[1-9][0-9]*$/;
14-
const INVALID_KEYS = ['-', '+', '.', 'e', 'E'];
10+
import {
11+
getDurationUnitValue,
12+
getDurationNumberInItsUnit,
13+
getTimeOptions,
14+
POSITIVE_INTEGER_REGEX,
15+
INVALID_NUMBER_KEYS,
16+
} from '../utils';
1517

1618
export interface DurationInputProps {
1719
value: string;
@@ -58,7 +60,7 @@ export const DurationInput = React.forwardRef<HTMLInputElement, DurationInputPro
5860
// Always update the displayed value so the input is responsive.
5961
setLocalNumber(val);
6062
// Only propagate to the form when the value is a valid positive integer.
61-
if (INTEGER_REGEX.test(val)) {
63+
if (POSITIVE_INTEGER_REGEX.test(val)) {
6264
const parsedValue = parseInt(val, 10);
6365
onChange(`${parsedValue}${intervalUnit}`);
6466
}
@@ -68,7 +70,7 @@ export const DurationInput = React.forwardRef<HTMLInputElement, DurationInputPro
6870

6971
// On blur, if the field is empty or invalid, restore the last valid form value.
7072
const onBlur = useCallback(() => {
71-
if (!INTEGER_REGEX.test(localNumber)) {
73+
if (!POSITIVE_INTEGER_REGEX.test(localNumber)) {
7274
setLocalNumber(String(intervalNumber ?? 1));
7375
}
7476
}, [localNumber, intervalNumber]);
@@ -81,7 +83,7 @@ export const DurationInput = React.forwardRef<HTMLInputElement, DurationInputPro
8183
);
8284

8385
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
84-
if (INVALID_KEYS.includes(e.key)) {
86+
if (INVALID_NUMBER_KEYS.includes(e.key)) {
8587
e.preventDefault();
8688
}
8789
}, []);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { fireEvent, render, screen } from '@testing-library/react';
10+
import { StateTransitionCountField } from './state_transition_count_field';
11+
import { createFormWrapper } from '../../test_utils';
12+
13+
describe('StateTransitionCountField', () => {
14+
it('renders the consecutive breaches count input', () => {
15+
render(<StateTransitionCountField />, {
16+
wrapper: createFormWrapper({ kind: 'alert' }),
17+
});
18+
19+
expect(screen.getByTestId('stateTransitionCountInput')).toBeInTheDocument();
20+
});
21+
22+
it('accepts a positive integer for count', () => {
23+
render(<StateTransitionCountField />, {
24+
wrapper: createFormWrapper({ kind: 'alert' }),
25+
});
26+
27+
const input = screen.getByTestId('stateTransitionCountInput');
28+
fireEvent.change(input, { target: { value: '3' } });
29+
expect(input).toHaveValue(3);
30+
});
31+
32+
it('applies max value of 1000', () => {
33+
render(<StateTransitionCountField />, {
34+
wrapper: createFormWrapper({ kind: 'alert' }),
35+
});
36+
37+
const input = screen.getByTestId('stateTransitionCountInput');
38+
expect(input).toHaveAttribute('max', '1000');
39+
});
40+
41+
it('renders with pre-filled state transition count from form state', () => {
42+
render(<StateTransitionCountField />, {
43+
wrapper: createFormWrapper({
44+
kind: 'alert',
45+
stateTransition: {
46+
pendingCount: 5,
47+
},
48+
}),
49+
});
50+
51+
expect(screen.getByTestId('stateTransitionCountInput')).toHaveValue(5);
52+
});
53+
});

0 commit comments

Comments
 (0)