Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cb02bad
Updating validation for recovery delay
doakalexi Sep 2, 2026
bce43cc
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 2, 2026
a5aa772
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 3, 2026
00f662b
Addressing PR comments
doakalexi Sep 8, 2026
9b736cc
Adding UI changes
doakalexi Sep 8, 2026
06a7cd9
Merge branch 'alerting-v2/update-validation-for-recovery-delay' of gi…
doakalexi Sep 8, 2026
57e01b6
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 8, 2026
d5f8df3
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 8, 2026
1eff571
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 8, 2026
d34739c
Fixing check after merge with main
doakalexi Sep 8, 2026
a3eeb71
Merge branch 'alerting-v2/update-validation-for-recovery-delay' of gi…
doakalexi Sep 8, 2026
7a88ad9
Merge branch 'main' of github.com:elastic/kibana into alerting-v2/upd…
doakalexi Sep 8, 2026
f26d7c5
Fixing test failures
doakalexi Sep 8, 2026
350a627
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 11, 2026
e95a043
Removing comments
doakalexi Sep 11, 2026
c6ea536
Update x-pack/platform/plugins/shared/alerting_v2/server/lib/errors/e…
doakalexi Sep 11, 2026
684bc24
Update x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting…
doakalexi Sep 11, 2026
11eae65
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ describe('RecoveryConditionStep', () => {
expect(screen.queryByTestId('composeDiscoverEditRecovery')).not.toBeInTheDocument();
});

it('renders the recovery delay field when recovery type is default', () => {
renderRecoveryStep('no_breach');

expect(screen.getByTestId('recoveryDelayFormRow')).toBeInTheDocument();
});

it('renders the recovery delay field when recovery type is custom', () => {
renderRecoveryStep('query', {}, CUSTOM_RECOVERY_QUERY);

expect(screen.getByTestId('recoveryDelayFormRow')).toBeInTheDocument();
});

it('hides the recovery delay field when recovery type is none (delay is inert)', () => {
renderRecoveryStep('none');

expect(screen.queryByTestId('recoveryDelayFormRow')).not.toBeInTheDocument();
});

it('renders query summaries and edit button in custom mode', () => {
renderRecoveryStep('query', {}, CUSTOM_RECOVERY_QUERY);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,18 @@ export function RecoveryConditionStep({
</>
)}

<EuiSpacer size="m" />
<RecoveryDelayField />
{/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: self-explanatory, comment not needed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed in this commit, e95a043

* Recovery delay only applies to condition-based recovery. When recovery is
* disabled ('none'), the delay is inert (and rejected by the write API), so we
* hide it. `no_data_strategy: 'recover'` does not re-enable it — the director
* bypasses recovering gating for no-data recovery.
*/}
{recoveryStrategy !== 'none' && (
<>
<EuiSpacer size="m" />
<RecoveryDelayField />
</>
)}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,28 @@ describe('composeFormToCreateRequest', () => {
expect(result.state_transition).toBeUndefined();
});

it('maps state_transition for immediate delay mode', () => {
it('maps state_transition for immediate delay mode (recovery disabled omits recovering_count)', () => {
const result = composeFormToCreateRequest(baseFormValues);
expect(result.state_transition).toEqual({ pending_count: 0 });
});

it('emits recovering_count: 0 for immediate delay mode when recovery is enabled', () => {
const values: FormValues = { ...baseFormValues, recoveryStrategy: 'no_breach' };
const result = composeFormToCreateRequest(values);
expect(result.state_transition).toEqual({ pending_count: 0, recovering_count: 0 });
});

it('omits recovering fields when recovery_strategy is "none" even if recovering values are set', () => {
const values: FormValues = {
...baseFormValues,
recoveryStrategy: 'none',
stateTransitionRecoveryDelayMode: 'recoveries',
stateTransition: { recoveringCount: 3 },
};
const result = composeFormToCreateRequest(values);
expect(result.state_transition).toEqual({ pending_count: 0 });
});

it('maps state_transition for breaches delay mode', () => {
const values: FormValues = {
...baseFormValues,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
deriveAlertDelayModeFromStateTransition,
deriveRecoveryDelayModeFromStateTransition,
} from '../../form/utils/state_transition_helpers';
import { resolveRecoveryStrategy } from '../../form/utils/rule_request_mappers';
import { isRecoveryEnabled, resolveRecoveryStrategy } from '../../form/utils/rule_request_mappers';
import type { FormValues } from '../../form/types';

const DELAY_IMMEDIATE = 'immediate';
Expand All @@ -42,15 +42,19 @@ const mapStateTransition = (formValues: FormValues) => {
if (stateTransition?.pendingCount != null) out.pending_count = stateTransition.pendingCount;
}

if (recoveryMode === DELAY_IMMEDIATE) {
out.recovering_count = 0;
} else if (recoveryMode !== DELAY_DURATION && stateTransition?.recoveringCount != null) {
out.recovering_count = stateTransition.recoveringCount;
} else if (recoveryMode === DELAY_DURATION) {
if (stateTransition?.recoveringTimeframe != null)
out.recovering_timeframe = stateTransition.recoveringTimeframe;
if (stateTransition?.recoveringCount != null)
// Recovering thresholds are only meaningful when recovery is enabled; emitting them

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: self explanatory

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed in this commit, e95a043

// while recovery is disabled is inert and rejected by the write API.
if (isRecoveryEnabled(formValues)) {
if (recoveryMode === DELAY_IMMEDIATE) {
out.recovering_count = 0;
} else if (recoveryMode !== DELAY_DURATION && stateTransition?.recoveringCount != null) {
out.recovering_count = stateTransition.recoveringCount;
} else if (recoveryMode === DELAY_DURATION) {
if (stateTransition?.recoveringTimeframe != null)
out.recovering_timeframe = stateTransition.recoveringTimeframe;
if (stateTransition?.recoveringCount != null)
out.recovering_count = stateTransition.recoveringCount;
}
}

return Object.keys(out).length ? out : undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ describe('AlertDelayField', () => {
{
wrapper: createFormWrapper({
kind: 'alert',
recoveryStrategy: 'no_breach',
stateTransitionAlertDelayMode: 'breaches',
stateTransitionRecoveryDelayMode: 'recoveries',
stateTransition: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ describe('RecoveryDelayField', () => {
{
wrapper: createFormWrapper({
kind: 'alert',
recoveryStrategy: 'no_breach',
stateTransitionAlertDelayMode: 'breaches',
stateTransitionRecoveryDelayMode: 'recoveries',
stateTransition: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ describe('rule_request_mappers', () => {
expect(result.state_transition).toEqual({
pending_count: 3,
pending_timeframe: '10m',
recovering_count: 0,
});
});

Expand All @@ -106,7 +105,7 @@ describe('rule_request_mappers', () => {

const result = mapFormValuesToRuleRequest(formValues);

expect(result.state_transition).toEqual({ pending_count: 5, recovering_count: 0 });
expect(result.state_transition).toEqual({ pending_count: 5 });
expect(result.state_transition).not.toHaveProperty('pending_timeframe');
});

Expand All @@ -122,10 +121,11 @@ describe('rule_request_mappers', () => {
expect(result.state_transition).toBeUndefined();
});

it('emits pending_count: 0 and recovering_count: 0 for alert kind when both modes are immediate', () => {
it('emits pending_count: 0 and recovering_count: 0 for an alert with recovery enabled when both modes are immediate', () => {
const formValues: FormValues = {
...baseFormValues,
kind: 'alert',
recoveryStrategy: 'no_breach',
stateTransition: {},
};

Expand All @@ -134,21 +134,49 @@ describe('rule_request_mappers', () => {
expect(result.state_transition).toEqual({ pending_count: 0, recovering_count: 0 });
});

it('emits pending_count: 0 and recovering_count: 0 for alert kind when stateTransition is undefined', () => {
it('omits recovering_count for an alert when recovery is disabled and both modes are immediate', () => {
const formValues: FormValues = {
...baseFormValues,
kind: 'alert',
stateTransition: {},
};

const result = mapFormValuesToRuleRequest(formValues);

expect(result.state_transition).toEqual({ pending_count: 0, recovering_count: 0 });
expect(result.state_transition).toEqual({ pending_count: 0 });
});

it('omits recovering_count for an alert when recovery is disabled and stateTransition is undefined', () => {
const formValues: FormValues = {
...baseFormValues,
kind: 'alert',
};

const result = mapFormValuesToRuleRequest(formValues);

expect(result.state_transition).toEqual({ pending_count: 0 });
});

it('omits recovering fields when recovery_strategy is "none" even if recovering values are set', () => {
const formValues: FormValues = {
...baseFormValues,
kind: 'alert',
recoveryStrategy: 'none',
stateTransitionAlertDelayMode: 'immediate',
stateTransitionRecoveryDelayMode: 'duration',
stateTransition: { recoveringCount: 3, recoveringTimeframe: '5m' },
};

const result = mapFormValuesToRuleRequest(formValues);

expect(result.state_transition).toEqual({ pending_count: 0 });
});

it('emits pending_count: 0 when alert delay mode is immediate even if pendingCount is stale', () => {
const formValues: FormValues = {
...baseFormValues,
kind: 'alert',
recoveryStrategy: 'no_breach',
stateTransitionAlertDelayMode: 'immediate',
stateTransitionRecoveryDelayMode: 'recoveries',
stateTransition: {
Expand All @@ -169,6 +197,7 @@ describe('rule_request_mappers', () => {
const formValues: FormValues = {
...baseFormValues,
kind: 'alert',
recoveryStrategy: 'no_breach',
stateTransitionAlertDelayMode: 'immediate',
stateTransitionRecoveryDelayMode: 'duration',
stateTransition: { recoveringCount: 4, recoveringTimeframe: '15m' },
Expand All @@ -187,6 +216,7 @@ describe('rule_request_mappers', () => {
const formValues: FormValues = {
...baseFormValues,
kind: 'alert',
recoveryStrategy: 'no_breach',
stateTransitionAlertDelayMode: 'immediate',
stateTransitionRecoveryDelayMode: 'recoveries',
stateTransition: { recoveringCount: 3 },
Expand All @@ -202,6 +232,7 @@ describe('rule_request_mappers', () => {
const formValues: FormValues = {
...baseFormValues,
kind: 'alert',
recoveryStrategy: 'no_breach',
stateTransitionAlertDelayMode: 'breaches',
stateTransitionRecoveryDelayMode: 'duration',
stateTransition: {
Expand Down Expand Up @@ -1006,10 +1037,11 @@ describe('rule_request_mappers', () => {
breach: { query: 'FROM logs-* | STATS count() BY host' },
});
expect(createPayload.grouping).toEqual({ fields: ['host.name'] });
// baseRuleResponse has no recovery_strategy, so recovery is disabled and the
// inert recovering_count is not emitted.
expect(createPayload.state_transition).toEqual({
pending_count: 3,
pending_timeframe: '10m',
recovering_count: 0,
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ export const resolveRecoveryStrategy = (
return formValues.query.recovery != null ? ('query' as const) : undefined;
};

/**
* Recovery is enabled only for condition-based strategies (`no_breach` / `query`).
* `none`/unset means condition-based recovery never fires, so the recovering delay
* thresholds (`recovering_count` / `recovering_timeframe`) are inert and must not be
* emitted — the write API rejects them (see `isRecoveryTransitionConsistentWithStrategy`).
* `no_data_strategy: 'recover'` does NOT re-enable them: the director bypasses recovering
* gating for no-data recovery (see `count_timeframe_strategy`).
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably not needed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed in this commit, e95a043

export const isRecoveryEnabled = (
formValues: Pick<FormValues, 'kind' | 'recoveryStrategy' | 'query'>
): boolean => {
const strategy = resolveRecoveryStrategy(formValues);
return strategy != null && strategy !== 'none';
};

// ---------------------------------------------------------------------------
// FormValues → API request
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -95,16 +110,20 @@ const mapStateTransition = (formValues: FormValues) => {
}
}

if (recoveryMode === DELAY_MODE.immediate) {
out.recovering_count = 0;
} else if (recoveryMode !== DELAY_MODE.duration && stateTransition?.recoveringCount != null) {
out.recovering_count = stateTransition.recoveringCount;
} else if (recoveryMode === DELAY_MODE.duration) {
if (stateTransition?.recoveringTimeframe != null) {
out.recovering_timeframe = stateTransition.recoveringTimeframe;
}
if (stateTransition?.recoveringCount != null) {
// Recovering thresholds are only meaningful when recovery is enabled; emitting them
// while recovery is disabled is inert and rejected by the write API.
if (isRecoveryEnabled(formValues)) {
if (recoveryMode === DELAY_MODE.immediate) {
out.recovering_count = 0;
} else if (recoveryMode !== DELAY_MODE.duration && stateTransition?.recoveringCount != null) {
out.recovering_count = stateTransition.recoveringCount;
} else if (recoveryMode === DELAY_MODE.duration) {
if (stateTransition?.recoveringTimeframe != null) {
out.recovering_timeframe = stateTransition.recoveringTimeframe;
}
if (stateTransition?.recoveringCount != null) {
out.recovering_count = stateTransition.recoveringCount;
}
}
}

Expand Down
Loading
Loading