diff --git a/x-pack/plugins/infra/common/alerting/metrics/index.ts b/x-pack/plugins/infra/common/alerting/metrics/index.ts index 5151a40c7e8b1..2c66638711cd0 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/index.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/index.ts @@ -10,8 +10,8 @@ export const INFRA_ALERT_PREVIEW_PATH = '/api/infra/alerting/preview'; export const TOO_MANY_BUCKETS_PREVIEW_EXCEPTION = 'TOO_MANY_BUCKETS_PREVIEW_EXCEPTION'; export interface TooManyBucketsPreviewExceptionMetadata { - TOO_MANY_BUCKETS_PREVIEW_EXCEPTION: any; - maxBuckets: number; + TOO_MANY_BUCKETS_PREVIEW_EXCEPTION: boolean; + maxBuckets: any; } export const isTooManyBucketsPreviewException = ( value: any diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index 47a5202cc7275..a89f82e931fd4 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -90,12 +90,17 @@ export type AlertPreviewRequestParams = rt.TypeOf = (props) => { return unthrottledNotifications > notifications; }, [previewResult, showNoDataResults]); + const hasWarningThreshold = useMemo( + () => alertParams.criteria?.some((c) => Reflect.has(c, 'warningThreshold')), + [alertParams] + ); + return ( = (props) => { - - - - ), - }} - />{' '} - {previewResult.groupByDisplayName ? ( - <> - {' '} - - - {' '} - - ) : null} - e.value === previewResult.previewLookbackInterval - )?.shortText, - }} - /> - + } > {showNoDataResults && previewResult.resultTotals.noData ? ( + ), boldedResultsNumber: ( {i18n.translate( 'xpack.infra.metrics.alertFlyout.alertPreviewNoDataResultNumber', { - defaultMessage: - '{noData, plural, one {was # result} other {were # results}}', + defaultMessage: '{noData, plural, one {# result} other {# results}}', values: { noData: previewResult.resultTotals.noData, }, @@ -361,6 +332,145 @@ export const AlertPreview: React.FC = (props) => { ); }; +const PreviewTextString = ({ + previewResult, + hasWarningThreshold, +}: { + previewResult: AlertPreviewSuccessResponsePayload & Record; + hasWarningThreshold: boolean; +}) => { + const instanceCount = hasWarningThreshold ? ( + + ), + criticalInstances: ( + + + + ), + warningInstances: ( + + + + ), + boldCritical: ( + + + + ), + boldWarning: ( + + + + ), + }} + /> + ) : ( + + ), + firedTimes: ( + + + + ), + }} + /> + ); + + const groupByText = previewResult.groupByDisplayName ? ( + <> + + + + ), + }} + />{' '} + + ) : ( + <> + ); + + const lookbackText = ( + e.value === previewResult.previewLookbackInterval) + ?.shortText, + }} + /> + ); + + return ( + + ); +}; + const previewOptions = [ { value: 'h', diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index d403c254f2bd0..4a05521e9fc87 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { debounce, pick } from 'lodash'; +import { debounce, pick, omit } from 'lodash'; import { Unit } from '@elastic/datemath'; import React, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react'; import { IFieldType } from 'src/plugins/data/public'; @@ -21,6 +21,7 @@ import { EuiCheckbox, EuiToolTip, EuiIcon, + EuiHealth, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -423,9 +424,24 @@ const StyledExpression = euiStyled.div` padding: 0 4px; `; +const StyledHealth = euiStyled(EuiHealth)` + margin-left: 4px; +`; + export const ExpressionRow: React.FC = (props) => { const { setAlertParams, expression, errors, expressionId, remove, canDelete, fields } = props; - const { metric, comparator = Comparator.GT, threshold = [], customMetric } = expression; + const { + metric, + comparator = Comparator.GT, + threshold = [], + customMetric, + warningThreshold = [], + warningComparator, + } = expression; + + const [displayWarningThreshold, setDisplayWarningThreshold] = useState( + Boolean(warningThreshold?.length) + ); const updateMetric = useCallback( (m?: SnapshotMetricType | string) => { @@ -452,6 +468,13 @@ export const ExpressionRow: React.FC = (props) => { [expressionId, expression, setAlertParams] ); + const updateWarningComparator = useCallback( + (c?: string) => { + setAlertParams(expressionId, { ...expression, warningComparator: c as Comparator }); + }, + [expressionId, expression, setAlertParams] + ); + const updateThreshold = useCallback( (t) => { if (t.join() !== expression.threshold.join()) { @@ -461,6 +484,58 @@ export const ExpressionRow: React.FC = (props) => { [expressionId, expression, setAlertParams] ); + const updateWarningThreshold = useCallback( + (t) => { + if (t.join() !== expression.warningThreshold?.join()) { + setAlertParams(expressionId, { ...expression, warningThreshold: t }); + } + }, + [expressionId, expression, setAlertParams] + ); + + const toggleWarningThreshold = useCallback(() => { + if (!displayWarningThreshold) { + setDisplayWarningThreshold(true); + setAlertParams(expressionId, { + ...expression, + warningComparator: comparator, + warningThreshold: [], + }); + } else { + setDisplayWarningThreshold(false); + setAlertParams(expressionId, omit(expression, 'warningComparator', 'warningThreshold')); + } + }, [ + displayWarningThreshold, + setDisplayWarningThreshold, + setAlertParams, + comparator, + expression, + expressionId, + ]); + + const criticalThresholdExpression = ( + + ); + + const warningThresholdExpression = displayWarningThreshold && ( + + ); + const ofFields = useMemo(() => { let myMetrics = hostMetricTypes; @@ -515,25 +590,62 @@ export const ExpressionRow: React.FC = (props) => { fields={fields} /> - - - - {metric && ( -
- {metricUnit[metric]?.label || ''} -
- )} + {!displayWarningThreshold && criticalThresholdExpression} + {displayWarningThreshold && ( + <> + + {criticalThresholdExpression} + + + + + + {warningThresholdExpression} + + + + + + + )} + {!displayWarningThreshold && ( + <> + {' '} + + + + + + + + )} {canDelete && ( @@ -553,6 +665,38 @@ export const ExpressionRow: React.FC = (props) => { ); }; +const ThresholdElement: React.FC<{ + updateComparator: (c?: string) => void; + updateThreshold: (t?: number[]) => void; + threshold: InventoryMetricConditions['threshold']; + comparator: InventoryMetricConditions['comparator']; + errors: IErrorObject; + metric?: SnapshotMetricType; +}> = ({ updateComparator, updateThreshold, threshold, metric, comparator, errors }) => { + return ( + <> + + + + {metric && ( +
+ {metricUnit[metric]?.label || ''} +
+ )} + + ); +}; + const getDisplayNameForType = (type: InventoryItemType) => { const inventoryModel = findInventoryModel(type); return inventoryModel.displayName; diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx index 7a87998c8ce1f..c8bab76d79a4e 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx @@ -6,8 +6,11 @@ */ import { i18n } from '@kbn/i18n'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; +import { + InventoryMetricConditions, + Comparator, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../server/lib/alerting/inventory_metric_threshold/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; @@ -21,8 +24,14 @@ export function validateMetricThreshold({ [id: string]: { timeSizeUnit: string[]; timeWindowSize: string[]; - threshold0: string[]; - threshold1: string[]; + critical: { + threshold0: string[]; + threshold1: string[]; + }; + warning: { + threshold0: string[]; + threshold1: string[]; + }; metric: string[]; }; } = {}; @@ -39,41 +48,64 @@ export function validateMetricThreshold({ errors[id] = errors[id] || { timeSizeUnit: [], timeWindowSize: [], - threshold0: [], - threshold1: [], + critical: { + threshold0: [], + threshold1: [], + }, + warning: { + threshold0: [], + threshold1: [], + }, metric: [], }; if (!c.threshold || !c.threshold.length) { - errors[id].threshold0.push( + errors[id].critical.threshold0.push( i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { defaultMessage: 'Threshold is required.', }) ); } - // The Threshold component returns an empty array with a length ([empty]) because it's using delete newThreshold[i]. - // We need to use [...c.threshold] to convert it to an array with an undefined value ([undefined]) so we can test each element. - if (c.threshold && c.threshold.length && ![...c.threshold].every(isNumber)) { - [...c.threshold].forEach((v, i) => { - if (!isNumber(v)) { - const key = i === 0 ? 'threshold0' : 'threshold1'; - errors[id][key].push( - i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdTypeRequired', { - defaultMessage: 'Thresholds must contain a valid number.', - }) - ); - } - }); - } - - if (c.comparator === 'between' && (!c.threshold || c.threshold.length < 2)) { - errors[id].threshold1.push( + if (c.warningThreshold && !c.warningThreshold.length) { + errors[id].warning.threshold0.push( i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { defaultMessage: 'Threshold is required.', }) ); } + for (const props of [ + { comparator: c.comparator, threshold: c.threshold, type: 'critical' }, + { comparator: c.warningComparator, threshold: c.warningThreshold, type: 'warning' }, + ]) { + // The Threshold component returns an empty array with a length ([empty]) because it's using delete newThreshold[i]. + // We need to use [...c.threshold] to convert it to an array with an undefined value ([undefined]) so we can test each element. + const { comparator, threshold, type } = props as { + comparator?: Comparator; + threshold?: number[]; + type: 'critical' | 'warning'; + }; + if (threshold && threshold.length && ![...threshold].every(isNumber)) { + [...threshold].forEach((v, i) => { + if (!isNumber(v)) { + const key = i === 0 ? 'threshold0' : 'threshold1'; + errors[id][type][key].push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdTypeRequired', { + defaultMessage: 'Thresholds must contain a valid number.', + }) + ); + } + }); + } + + if (comparator === Comparator.BETWEEN && (!threshold || threshold.length < 2)) { + errors[id][type].threshold1.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { + defaultMessage: 'Threshold is required.', + }) + ); + } + } if (!c.timeSize) { errors[id].timeWindowSize.push( diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx index 65842089863f3..c98984b5475cd 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -111,7 +111,9 @@ export const ExpressionChart: React.FC = ({ ); } - const thresholds = expression.threshold.slice().sort(); + const criticalThresholds = expression.threshold.slice().sort(); + const warningThresholds = expression.warningThreshold?.slice().sort() ?? []; + const thresholds = [...criticalThresholds, ...warningThresholds].sort(); // Creating a custom series where the ID is changed to 0 // so that we can get a proper domian @@ -145,108 +147,70 @@ export const ExpressionChart: React.FC = ({ const dataDomain = calculateDomain(series, [metric], false); const domain = { max: Math.max(dataDomain.max, last(thresholds) || dataDomain.max) * 1.1, // add 10% headroom. - min: Math.min(dataDomain.min, first(thresholds) || dataDomain.min), + min: Math.min(dataDomain.min, first(thresholds) || dataDomain.min) * 0.9, // add 10% floor, }; if (domain.min === first(expression.threshold)) { domain.min = domain.min * 0.9; } - const isAbove = [Comparator.GT, Comparator.GT_OR_EQ].includes(expression.comparator); - const isBelow = [Comparator.LT, Comparator.LT_OR_EQ].includes(expression.comparator); const opacity = 0.3; const { timeSize, timeUnit } = expression; const timeLabel = TIME_LABELS[timeUnit as keyof typeof TIME_LABELS]; - return ( - <> - - - - ({ - dataValue: threshold, - }))} - style={{ - line: { - strokeWidth: 2, - stroke: colorTransformer(Color.color1), - opacity: 1, - }, - }} - /> - {thresholds.length === 2 && expression.comparator === Comparator.BETWEEN ? ( - <> - - - ) : null} - {thresholds.length === 2 && expression.comparator === Comparator.OUTSIDE_RANGE ? ( - <> - - & { sortedThresholds: number[]; color: Color; id: string }) => { + if (!comparator || !threshold) return null; + const isAbove = [Comparator.GT, Comparator.GT_OR_EQ].includes(comparator); + const isBelow = [Comparator.LT, Comparator.LT_OR_EQ].includes(comparator); + return ( + <> + ({ + dataValue: t, + }))} + style={{ + line: { + strokeWidth: 2, + stroke: colorTransformer(color), + opacity: 1, + }, + }} + /> + {sortedThresholds.length === 2 && comparator === Comparator.BETWEEN ? ( + <> + - - ) : null} - {isBelow && first(expression.threshold) != null ? ( + }, + ]} + /> + + ) : null} + {sortedThresholds.length === 2 && comparator === Comparator.OUTSIDE_RANGE ? ( + <> = ({ x0: firstTimestamp, x1: lastTimestamp, y0: domain.min, - y1: first(expression.threshold), + y1: first(threshold), }, }, ]} /> - ) : null} - {isAbove && first(expression.threshold) != null ? ( = ({ coordinates: { x0: firstTimestamp, x1: lastTimestamp, - y0: first(expression.threshold), + y0: last(threshold), y1: domain.max, }, }, ]} /> - ) : null} + + ) : null} + {isBelow && first(threshold) != null ? ( + + ) : null} + {isAbove && first(threshold) != null ? ( + + ) : null} + + ); + }; + + return ( + <> + + + + + {expression.warningComparator && expression.warningThreshold && ( + + )} = (props) => { const [isExpanded, setRowState] = useState(true); const toggleRowState = useCallback(() => setRowState(!isExpanded), [isExpanded]); @@ -85,9 +92,14 @@ export const ExpressionRow: React.FC = (props) => { metric, comparator = Comparator.GT, threshold = [], + warningThreshold = [], + warningComparator, } = expression; + const [displayWarningThreshold, setDisplayWarningThreshold] = useState( + Boolean(warningThreshold?.length) + ); - const isMetricPct = useMemo(() => metric && metric.endsWith('.pct'), [metric]); + const isMetricPct = useMemo(() => Boolean(metric && metric.endsWith('.pct')), [metric]); const updateAggType = useCallback( (at: string) => { @@ -114,22 +126,81 @@ export const ExpressionRow: React.FC = (props) => { [expressionId, expression, setAlertParams] ); + const updateWarningComparator = useCallback( + (c?: string) => { + setAlertParams(expressionId, { ...expression, warningComparator: c as Comparator }); + }, + [expressionId, expression, setAlertParams] + ); + + const convertThreshold = useCallback( + (enteredThreshold) => + isMetricPct ? enteredThreshold.map((v: number) => pctToDecimal(v)) : enteredThreshold, + [isMetricPct] + ); + const updateThreshold = useCallback( (enteredThreshold) => { - const t = isMetricPct - ? enteredThreshold.map((v: number) => pctToDecimal(v)) - : enteredThreshold; + const t = convertThreshold(enteredThreshold); if (t.join() !== expression.threshold.join()) { setAlertParams(expressionId, { ...expression, threshold: t }); } }, - [expressionId, expression, isMetricPct, setAlertParams] + [expressionId, expression, convertThreshold, setAlertParams] ); - const displayedThreshold = useMemo(() => { - if (isMetricPct) return threshold.map((v) => decimalToPct(v)); - return threshold; - }, [threshold, isMetricPct]); + const updateWarningThreshold = useCallback( + (enteredThreshold) => { + const t = convertThreshold(enteredThreshold); + if (t.join() !== expression.warningThreshold?.join()) { + setAlertParams(expressionId, { ...expression, warningThreshold: t }); + } + }, + [expressionId, expression, convertThreshold, setAlertParams] + ); + + const toggleWarningThreshold = useCallback(() => { + if (!displayWarningThreshold) { + setDisplayWarningThreshold(true); + setAlertParams(expressionId, { + ...expression, + warningComparator: comparator, + warningThreshold: [], + }); + } else { + setDisplayWarningThreshold(false); + setAlertParams(expressionId, omit(expression, 'warningComparator', 'warningThreshold')); + } + }, [ + displayWarningThreshold, + setDisplayWarningThreshold, + setAlertParams, + comparator, + expression, + expressionId, + ]); + + const criticalThresholdExpression = ( + + ); + + const warningThresholdExpression = displayWarningThreshold && ( + + ); return ( <> @@ -187,26 +258,62 @@ export const ExpressionRow: React.FC = (props) => { /> )} - - - - {isMetricPct && ( -
- % -
- )} + {!displayWarningThreshold && criticalThresholdExpression} + {displayWarningThreshold && ( + <> + + {criticalThresholdExpression} + + + + + + {warningThresholdExpression} + + + + + + + )} + {!displayWarningThreshold && ( + <> + {' '} + + + + + + + + )}
{canDelete && ( @@ -227,6 +334,44 @@ export const ExpressionRow: React.FC = (props) => { ); }; +const ThresholdElement: React.FC<{ + updateComparator: (c?: string) => void; + updateThreshold: (t?: number[]) => void; + threshold: MetricExpression['threshold']; + isMetricPct: boolean; + comparator: MetricExpression['comparator']; + errors: IErrorObject; +}> = ({ updateComparator, updateThreshold, threshold, isMetricPct, comparator, errors }) => { + const displayedThreshold = useMemo(() => { + if (isMetricPct) return threshold.map((v) => decimalToPct(v)); + return threshold; + }, [threshold, isMetricPct]); + + return ( + <> + + + + {isMetricPct && ( +
+ % +
+ )} + + ); +}; + export const aggregationType: { [key: string]: any } = { avg: { text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.avg', { diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx index bab396df9da0d..69b2f1d1bcc8f 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx @@ -25,8 +25,14 @@ export function validateMetricThreshold({ aggField: string[]; timeSizeUnit: string[]; timeWindowSize: string[]; - threshold0: string[]; - threshold1: string[]; + critical: { + threshold0: string[]; + threshold1: string[]; + }; + warning: { + threshold0: string[]; + threshold1: string[]; + }; metric: string[]; }; } = {}; @@ -44,8 +50,14 @@ export function validateMetricThreshold({ aggField: [], timeSizeUnit: [], timeWindowSize: [], - threshold0: [], - threshold1: [], + critical: { + threshold0: [], + threshold1: [], + }, + warning: { + threshold0: [], + threshold1: [], + }, metric: [], }; if (!c.aggType) { @@ -57,36 +69,54 @@ export function validateMetricThreshold({ } if (!c.threshold || !c.threshold.length) { - errors[id].threshold0.push( + errors[id].critical.threshold0.push( i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { defaultMessage: 'Threshold is required.', }) ); } - // The Threshold component returns an empty array with a length ([empty]) because it's using delete newThreshold[i]. - // We need to use [...c.threshold] to convert it to an array with an undefined value ([undefined]) so we can test each element. - if (c.threshold && c.threshold.length && ![...c.threshold].every(isNumber)) { - [...c.threshold].forEach((v, i) => { - if (!isNumber(v)) { - const key = i === 0 ? 'threshold0' : 'threshold1'; - errors[id][key].push( - i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdTypeRequired', { - defaultMessage: 'Thresholds must contain a valid number.', - }) - ); - } - }); - } - - if (c.comparator === Comparator.BETWEEN && (!c.threshold || c.threshold.length < 2)) { - errors[id].threshold1.push( + if (c.warningThreshold && !c.warningThreshold.length) { + errors[id].warning.threshold0.push( i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { defaultMessage: 'Threshold is required.', }) ); } + for (const props of [ + { comparator: c.comparator, threshold: c.threshold, type: 'critical' }, + { comparator: c.warningComparator, threshold: c.warningThreshold, type: 'warning' }, + ]) { + // The Threshold component returns an empty array with a length ([empty]) because it's using delete newThreshold[i]. + // We need to use [...c.threshold] to convert it to an array with an undefined value ([undefined]) so we can test each element. + const { comparator, threshold, type } = props as { + comparator?: Comparator; + threshold?: number[]; + type: 'critical' | 'warning'; + }; + if (threshold && threshold.length && ![...threshold].every(isNumber)) { + [...threshold].forEach((v, i) => { + if (!isNumber(v)) { + const key = i === 0 ? 'threshold0' : 'threshold1'; + errors[id][type][key].push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdTypeRequired', { + defaultMessage: 'Thresholds must contain a valid number.', + }) + ); + } + }); + } + + if (comparator === Comparator.BETWEEN && (!threshold || threshold.length < 2)) { + errors[id][type].threshold1.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { + defaultMessage: 'Threshold is required.', + }) + ); + } + } + if (!c.timeSize) { errors[id].timeWindowSize.push( i18n.translate('xpack.infra.metrics.alertFlyout.error.timeRequred', { diff --git a/x-pack/plugins/infra/server/lib/alerting/common/messages.ts b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts index 9f0be1679448f..b692629209849 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/messages.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts @@ -19,6 +19,9 @@ export const stateToAlertMessage = { [AlertStates.ALERT]: i18n.translate('xpack.infra.metrics.alerting.threshold.alertState', { defaultMessage: 'ALERT', }), + [AlertStates.WARNING]: i18n.translate('xpack.infra.metrics.alerting.threshold.warningState', { + defaultMessage: 'WARNING', + }), [AlertStates.NO_DATA]: i18n.translate('xpack.infra.metrics.alerting.threshold.noDataState', { defaultMessage: 'NO DATA', }), diff --git a/x-pack/plugins/infra/server/lib/alerting/common/types.ts b/x-pack/plugins/infra/server/lib/alerting/common/types.ts index e4db2600e316d..0b809429de0d2 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/types.ts @@ -29,6 +29,15 @@ export enum Aggregators { export enum AlertStates { OK, ALERT, + WARNING, NO_DATA, ERROR, } + +export interface PreviewResult { + fired: number; + warning: number; + noData: number; + error: number; + notifications: number; +} diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 16f74d579969a..ea37f7adda7c4 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -26,6 +26,7 @@ import { getNodes } from '../../../routes/snapshot/lib/get_nodes'; type ConditionResult = InventoryMetricConditions & { shouldFire: boolean[]; + shouldWarn: boolean[]; currentValue: number; isNoData: boolean[]; isError: boolean; @@ -39,8 +40,8 @@ export const evaluateCondition = async ( filterQuery?: string, lookbackSize?: number ): Promise> => { - const { comparator, metric, customMetric } = condition; - let { threshold } = condition; + const { comparator, warningComparator, metric, customMetric } = condition; + let { threshold, warningThreshold } = condition; const timerange = { to: Date.now(), @@ -62,19 +63,22 @@ export const evaluateCondition = async ( ); threshold = threshold.map((n) => convertMetricValue(metric, n)); - - const comparisonFunction = comparatorMap[comparator]; + warningThreshold = warningThreshold?.map((n) => convertMetricValue(metric, n)); + + const valueEvaluator = (value?: DataValue, t?: number[], c?: Comparator) => { + if (value === undefined || value === null || !t || !c) return [false]; + const comparisonFunction = comparatorMap[c]; + return Array.isArray(value) + ? value.map((v) => comparisonFunction(Number(v), t)) + : [comparisonFunction(value as number, t)]; + }; const result = mapValues(currentValues, (value) => { if (isTooManyBucketsPreviewException(value)) throw value; return { ...condition, - shouldFire: - value !== undefined && - value !== null && - (Array.isArray(value) - ? value.map((v) => comparisonFunction(Number(v), threshold)) - : [comparisonFunction(value as number, threshold)]), + shouldFire: valueEvaluator(value, threshold, comparator), + shouldWarn: valueEvaluator(value, warningThreshold, warningComparator), isNoData: Array.isArray(value) ? value.map((v) => v === null) : [value === null], isError: value === undefined, currentValue: getCurrentValue(value), @@ -90,6 +94,7 @@ const getCurrentValue: (value: any) => number = (value) => { return NaN; }; +type DataValue = number | null | Array; const getData = async ( callCluster: AlertServices['callCluster'], nodeType: InventoryItemType, diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 2658fa6820274..a15f1010194a5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -81,6 +81,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = // Grab the result of the most recent bucket last(result[item].shouldFire) ); + const shouldAlertWarn = results.every((result) => last(result[item].shouldWarn)); // AND logic; because we need to evaluate all criteria, if one of them reports no data then the // whole alert is in a No Data/Error state @@ -93,12 +94,20 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = ? AlertStates.NO_DATA : shouldAlertFire ? AlertStates.ALERT + : shouldAlertWarn + ? AlertStates.WARNING : AlertStates.OK; let reason; - if (nextState === AlertStates.ALERT) { + if (nextState === AlertStates.ALERT || nextState === AlertStates.WARNING) { reason = results - .map((result) => buildReasonWithVerboseMetricName(result[item], buildFiredAlertReason)) + .map((result) => + buildReasonWithVerboseMetricName( + result[item], + buildFiredAlertReason, + nextState === AlertStates.WARNING + ) + ) .join('\n'); } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { /* @@ -125,7 +134,11 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = } if (reason) { const actionGroupId = - nextState === AlertStates.OK ? RecoveredActionGroup.id : FIRED_ACTIONS_ID; + nextState === AlertStates.OK + ? RecoveredActionGroup.id + : nextState === AlertStates.WARNING + ? WARNING_ACTIONS.id + : FIRED_ACTIONS.id; alertInstance.scheduleActions( /** * TODO: We're lying to the compiler here as explicitly calling `scheduleActions` on @@ -152,7 +165,11 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = } }; -const buildReasonWithVerboseMetricName = (resultItem: any, buildReason: (r: any) => string) => { +const buildReasonWithVerboseMetricName = ( + resultItem: any, + buildReason: (r: any) => string, + useWarningThreshold?: boolean +) => { if (!resultItem) return ''; const resultWithVerboseMetricName = { ...resultItem, @@ -162,6 +179,8 @@ const buildReasonWithVerboseMetricName = (resultItem: any, buildReason: (r: any) ? getCustomMetricLabel(resultItem.customMetric) : resultItem.metric), currentValue: formatMetric(resultItem.metric, resultItem.currentValue), + threshold: useWarningThreshold ? resultItem.warningThreshold! : resultItem.threshold, + comparator: useWarningThreshold ? resultItem.warningComparator! : resultItem.comparator, }; return buildReason(resultWithVerboseMetricName); }; @@ -177,11 +196,18 @@ const mapToConditionsLookup = ( {} ); -export const FIRED_ACTIONS_ID = 'metrics.invenotry_threshold.fired'; +export const FIRED_ACTIONS_ID = 'metrics.inventory_threshold.fired'; export const FIRED_ACTIONS: ActionGroup = { id: FIRED_ACTIONS_ID, name: i18n.translate('xpack.infra.metrics.alerting.inventory.threshold.fired', { - defaultMessage: 'Fired', + defaultMessage: 'Alert', + }), +}; +export const WARNING_ACTIONS_ID = 'metrics.inventory_threshold.warning'; +export const WARNING_ACTIONS = { + id: WARNING_ACTIONS_ID, + name: i18n.translate('xpack.infra.metrics.alerting.threshold.warning', { + defaultMessage: 'Warning', }), }; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts index 528c0f92d20e7..5fff76260e5c6 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -7,6 +7,7 @@ import { Unit } from '@elastic/datemath'; import { first } from 'lodash'; +import { PreviewResult } from '../common/types'; import { InventoryMetricConditions } from './types'; import { TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, @@ -35,7 +36,9 @@ interface PreviewInventoryMetricThresholdAlertParams { alertOnNoData: boolean; } -export const previewInventoryMetricThresholdAlert = async ({ +export const previewInventoryMetricThresholdAlert: ( + params: PreviewInventoryMetricThresholdAlertParams +) => Promise = async ({ callCluster, params, source, @@ -43,7 +46,7 @@ export const previewInventoryMetricThresholdAlert = async ({ alertInterval, alertThrottle, alertOnNoData, -}: PreviewInventoryMetricThresholdAlertParams) => { +}) => { const { criteria, filterQuery, nodeType } = params as InventoryMetricThresholdParams; if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions'); @@ -74,6 +77,7 @@ export const previewInventoryMetricThresholdAlert = async ({ const numberOfResultBuckets = lookbackSize; const numberOfExecutionBuckets = Math.floor(numberOfResultBuckets / alertResultsPerExecution); let numberOfTimesFired = 0; + let numberOfTimesWarned = 0; let numberOfNoDataResults = 0; let numberOfErrors = 0; let numberOfNotifications = 0; @@ -88,6 +92,9 @@ export const previewInventoryMetricThresholdAlert = async ({ const shouldFire = result[item].shouldFire as boolean[]; return shouldFire[mappedBucketIndex]; }); + const allConditionsWarnInMappedBucket = + !allConditionsFiredInMappedBucket && + results.every((result) => result[item].shouldWarn[mappedBucketIndex]); const someConditionsNoDataInMappedBucket = results.some((result) => { const hasNoData = result[item].isNoData as boolean[]; return hasNoData[mappedBucketIndex]; @@ -108,6 +115,9 @@ export const previewInventoryMetricThresholdAlert = async ({ } else if (allConditionsFiredInMappedBucket) { numberOfTimesFired++; notifyWithThrottle(); + } else if (allConditionsWarnInMappedBucket) { + numberOfTimesWarned++; + notifyWithThrottle(); } else if (throttleTracker > 0) { throttleTracker++; } @@ -115,7 +125,13 @@ export const previewInventoryMetricThresholdAlert = async ({ throttleTracker = 0; } } - return [numberOfTimesFired, numberOfNoDataResults, numberOfErrors, numberOfNotifications]; + return { + fired: numberOfTimesFired, + warning: numberOfTimesWarned, + noData: numberOfNoDataResults, + error: numberOfErrors, + notifications: numberOfNotifications, + }; }); return previewResults; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index 4ae1a0e4d5d49..6c439225d9d00 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -7,11 +7,17 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; -import { AlertType, AlertInstanceState, AlertInstanceContext } from '../../../../../alerts/server'; +import { + AlertType, + AlertInstanceState, + AlertInstanceContext, + ActionGroupIdsOf, +} from '../../../../../alerts/server'; import { createInventoryMetricThresholdExecutor, FIRED_ACTIONS, FIRED_ACTIONS_ID, + WARNING_ACTIONS, } from './inventory_metric_threshold_executor'; import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; import { InfraBackendLibs } from '../../infra_types'; @@ -25,7 +31,6 @@ import { metricActionVariableDescription, thresholdActionVariableDescription, } from '../common/messages'; -import { RecoveredActionGroupId } from '../../../../../alerts/common'; const condition = schema.object({ threshold: schema.arrayOf(schema.number()), @@ -33,6 +38,8 @@ const condition = schema.object({ timeUnit: schema.string(), timeSize: schema.number(), metric: schema.string(), + warningThreshold: schema.maybe(schema.arrayOf(schema.number())), + warningComparator: schema.maybe(oneOfLiterals(Object.values(Comparator))), customMetric: schema.maybe( schema.object({ type: schema.literal('custom'), @@ -44,7 +51,9 @@ const condition = schema.object({ ), }); -export type InventoryMetricThresholdAllowedActionGroups = typeof FIRED_ACTIONS_ID; +export type InventoryMetricThresholdAllowedActionGroups = ActionGroupIdsOf< + typeof FIRED_ACTIONS | typeof WARNING_ACTIONS +>; export const registerMetricInventoryThresholdAlertType = ( libs: InfraBackendLibs @@ -56,8 +65,7 @@ export const registerMetricInventoryThresholdAlertType = ( Record, AlertInstanceState, AlertInstanceContext, - InventoryMetricThresholdAllowedActionGroups, - RecoveredActionGroupId + InventoryMetricThresholdAllowedActionGroups > => ({ id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, name: i18n.translate('xpack.infra.metrics.inventory.alertName', { @@ -78,7 +86,7 @@ export const registerMetricInventoryThresholdAlertType = ( ), }, defaultActionGroupId: FIRED_ACTIONS_ID, - actionGroups: [FIRED_ACTIONS], + actionGroups: [FIRED_ACTIONS, WARNING_ACTIONS], producer: 'infrastructure', minimumLicenseRequired: 'basic', executor: createInventoryMetricThresholdExecutor(libs), diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts index 28c41de9b10d6..120fa47c079ab 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts @@ -22,4 +22,6 @@ export interface InventoryMetricConditions { threshold: number[]; comparator: Comparator; customMetric?: SnapshotCustomMetricInput; + warningThreshold?: number[]; + warningComparator?: Comparator; } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts index f9661e2cd56bb..029445a441eea 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -60,8 +60,17 @@ export const evaluateAlert = { + if (!t || !c) return [false]; + const comparisonFunction = comparatorMap[c]; + return Array.isArray(points) + ? points.map( + (point) => t && typeof point.value === 'number' && comparisonFunction(point.value, t) + ) + : [false]; + }; + return mapValues(currentValues, (points: any[] | typeof NaN | null) => { if (isTooManyBucketsPreviewException(points)) throw points; return { @@ -69,12 +78,8 @@ export const evaluateAlert = - typeof point.value === 'number' && comparisonFunction(point.value, threshold) - ) - : [false], + shouldFire: pointsEvaluator(points, threshold, comparator), + shouldWarn: pointsEvaluator(points, warningThreshold, warningComparator), isNoData: Array.isArray(points) ? points.map((point) => point?.value === null || point === null) : [points === null], diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 17b9ab1cab907..b822d71b3f812 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -18,7 +18,7 @@ import { stateToAlertMessage, } from '../common/messages'; import { createFormatter } from '../../../../common/formatters'; -import { AlertStates } from './types'; +import { AlertStates, Comparator } from './types'; import { evaluateAlert, EvaluatedAlertParams } from './lib/evaluate_alert'; import { MetricThresholdAlertExecutorOptions, @@ -60,6 +60,7 @@ export const createMetricThresholdExecutor = ( // Grab the result of the most recent bucket last(result[group].shouldFire) ); + const shouldAlertWarn = alertResults.every((result) => last(result[group].shouldWarn)); // AND logic; because we need to evaluate all criteria, if one of them reports no data then the // whole alert is in a No Data/Error state const isNoData = alertResults.some((result) => last(result[group].isNoData)); @@ -71,12 +72,18 @@ export const createMetricThresholdExecutor = ( ? AlertStates.NO_DATA : shouldAlertFire ? AlertStates.ALERT + : shouldAlertWarn + ? AlertStates.WARNING : AlertStates.OK; let reason; - if (nextState === AlertStates.ALERT) { + if (nextState === AlertStates.ALERT || nextState === AlertStates.WARNING) { reason = alertResults - .map((result) => buildFiredAlertReason(formatAlertResult(result[group]))) + .map((result) => + buildFiredAlertReason( + formatAlertResult(result[group], nextState === AlertStates.WARNING) + ) + ) .join('\n'); } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { /* @@ -105,7 +112,11 @@ export const createMetricThresholdExecutor = ( const firstResult = first(alertResults); const timestamp = (firstResult && firstResult[group].timestamp) ?? moment().toISOString(); const actionGroupId = - nextState === AlertStates.OK ? RecoveredActionGroup.id : FIRED_ACTIONS.id; + nextState === AlertStates.OK + ? RecoveredActionGroup.id + : nextState === AlertStates.WARNING + ? WARNING_ACTIONS.id + : FIRED_ACTIONS.id; alertInstance.scheduleActions(actionGroupId, { group, alertState: stateToAlertMessage[nextState], @@ -132,7 +143,14 @@ export const createMetricThresholdExecutor = ( export const FIRED_ACTIONS = { id: 'metrics.threshold.fired', name: i18n.translate('xpack.infra.metrics.alerting.threshold.fired', { - defaultMessage: 'Fired', + defaultMessage: 'Alert', + }), +}; + +export const WARNING_ACTIONS = { + id: 'metrics.threshold.warning', + name: i18n.translate('xpack.infra.metrics.alerting.threshold.warning', { + defaultMessage: 'Warning', }), }; @@ -152,9 +170,20 @@ const formatAlertResult = ( metric: string; currentValue: number; threshold: number[]; - } & AlertResult + comparator: Comparator; + warningThreshold?: number[]; + warningComparator?: Comparator; + } & AlertResult, + useWarningThreshold?: boolean ) => { - const { metric, currentValue, threshold } = alertResult; + const { + metric, + currentValue, + threshold, + comparator, + warningThreshold, + warningComparator, + } = alertResult; const noDataValue = i18n.translate( 'xpack.infra.metrics.alerting.threshold.noDataFormattedValue', { @@ -167,12 +196,17 @@ const formatAlertResult = ( currentValue: currentValue ?? noDataValue, }; const formatter = createFormatter('percent'); + const thresholdToFormat = useWarningThreshold ? warningThreshold! : threshold; + const comparatorToFormat = useWarningThreshold ? warningComparator! : comparator; return { ...alertResult, currentValue: currentValue !== null && typeof currentValue !== 'undefined' ? formatter(currentValue) : noDataValue, - threshold: Array.isArray(threshold) ? threshold.map((v: number) => formatter(v)) : threshold, + threshold: Array.isArray(thresholdToFormat) + ? thresholdToFormat.map((v: number) => formatter(v)) + : thresholdToFormat, + comparator: comparatorToFormat, }; }; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts index 8576fd7b59299..1adca25504b1f 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts @@ -20,10 +20,10 @@ describe('Previewing the metric threshold alert type', () => { alertThrottle: '1m', alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; - expect(firedResults).toBe(30); - expect(noDataResults).toBe(0); - expect(errorResults).toBe(0); + const { fired, noData, error, notifications } = ungroupedResult; + expect(fired).toBe(30); + expect(noData).toBe(0); + expect(error).toBe(0); expect(notifications).toBe(30); }); @@ -35,10 +35,10 @@ describe('Previewing the metric threshold alert type', () => { alertThrottle: '3m', alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; - expect(firedResults).toBe(10); - expect(noDataResults).toBe(0); - expect(errorResults).toBe(0); + const { fired, noData, error, notifications } = ungroupedResult; + expect(fired).toBe(10); + expect(noData).toBe(0); + expect(error).toBe(0); expect(notifications).toBe(10); }); test('returns the expected results using a bucket interval longer than the alert interval', async () => { @@ -49,10 +49,10 @@ describe('Previewing the metric threshold alert type', () => { alertThrottle: '30s', alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; - expect(firedResults).toBe(60); - expect(noDataResults).toBe(0); - expect(errorResults).toBe(0); + const { fired, noData, error, notifications } = ungroupedResult; + expect(fired).toBe(60); + expect(noData).toBe(0); + expect(error).toBe(0); expect(notifications).toBe(60); }); test('returns the expected results using a throttle interval longer than the alert interval', async () => { @@ -63,10 +63,10 @@ describe('Previewing the metric threshold alert type', () => { alertThrottle: '3m', alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; - expect(firedResults).toBe(30); - expect(noDataResults).toBe(0); - expect(errorResults).toBe(0); + const { fired, noData, error, notifications } = ungroupedResult; + expect(fired).toBe(30); + expect(noData).toBe(0); + expect(error).toBe(0); expect(notifications).toBe(15); }); }); @@ -83,15 +83,25 @@ describe('Previewing the metric threshold alert type', () => { alertThrottle: '1m', alertOnNoData: true, }); - const [firedResultsA, noDataResultsA, errorResultsA, notificationsA] = resultA; - expect(firedResultsA).toBe(30); - expect(noDataResultsA).toBe(0); - expect(errorResultsA).toBe(0); + const { + fired: firedA, + noData: noDataA, + error: errorA, + notifications: notificationsA, + } = resultA; + expect(firedA).toBe(30); + expect(noDataA).toBe(0); + expect(errorA).toBe(0); expect(notificationsA).toBe(30); - const [firedResultsB, noDataResultsB, errorResultsB, notificationsB] = resultB; - expect(firedResultsB).toBe(60); - expect(noDataResultsB).toBe(0); - expect(errorResultsB).toBe(0); + const { + fired: firedB, + noData: noDataB, + error: errorB, + notifications: notificationsB, + } = resultB; + expect(firedB).toBe(60); + expect(noDataB).toBe(0); + expect(errorB).toBe(0); expect(notificationsB).toBe(60); }); }); @@ -113,10 +123,10 @@ describe('Previewing the metric threshold alert type', () => { alertThrottle: '1m', alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; - expect(firedResults).toBe(25); - expect(noDataResults).toBe(10); - expect(errorResults).toBe(0); + const { fired, noData, error, notifications } = ungroupedResult; + expect(fired).toBe(25); + expect(noData).toBe(10); + expect(error).toBe(0); expect(notifications).toBe(35); }); }); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts index ac6372a94b1fe..b9fa6659d5fcd 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts @@ -14,6 +14,7 @@ import { import { ILegacyScopedClusterClient } from '../../../../../../../src/core/server'; import { InfraSource } from '../../../../common/http_api/source_api'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { PreviewResult } from '../common/types'; import { MetricExpressionParams } from './types'; import { evaluateAlert } from './lib/evaluate_alert'; @@ -39,7 +40,7 @@ export const previewMetricThresholdAlert: ( params: PreviewMetricThresholdAlertParams, iterations?: number, precalculatedNumberOfGroups?: number -) => Promise = async ( +) => Promise = async ( { callCluster, params, @@ -98,6 +99,7 @@ export const previewMetricThresholdAlert: ( numberOfResultBuckets / alertResultsPerExecution ); let numberOfTimesFired = 0; + let numberOfTimesWarned = 0; let numberOfNoDataResults = 0; let numberOfErrors = 0; let numberOfNotifications = 0; @@ -111,6 +113,9 @@ export const previewMetricThresholdAlert: ( const allConditionsFiredInMappedBucket = alertResults.every( (alertResult) => alertResult[group].shouldFire[mappedBucketIndex] ); + const allConditionsWarnInMappedBucket = + !allConditionsFiredInMappedBucket && + alertResults.every((alertResult) => alertResult[group].shouldWarn[mappedBucketIndex]); const someConditionsNoDataInMappedBucket = alertResults.some((alertResult) => { const hasNoData = alertResult[group].isNoData as boolean[]; return hasNoData[mappedBucketIndex]; @@ -131,6 +136,9 @@ export const previewMetricThresholdAlert: ( } else if (allConditionsFiredInMappedBucket) { numberOfTimesFired++; notifyWithThrottle(); + } else if (allConditionsWarnInMappedBucket) { + numberOfTimesWarned++; + notifyWithThrottle(); } else if (throttleTracker > 0) { throttleTracker += alertIntervalInSeconds; } @@ -138,7 +146,13 @@ export const previewMetricThresholdAlert: ( throttleTracker = 0; } } - return [numberOfTimesFired, numberOfNoDataResults, numberOfErrors, numberOfNotifications]; + return { + fired: numberOfTimesFired, + warning: numberOfTimesWarned, + noData: numberOfNoDataResults, + error: numberOfErrors, + notifications: numberOfNotifications, + }; }) ); return previewResults; @@ -199,7 +213,12 @@ export const previewMetricThresholdAlert: ( .reduce((a, b) => { if (!a) return b; if (!b) return a; - return [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]]; + const res = { ...a }; + const entries = (Object.entries(b) as unknown) as Array<[keyof PreviewResult, number]>; + for (const [key, value] of entries) { + res[key] += value; + } + return res; }) ); return zippedResult; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 6d8790f4f430c..e5e3a7bff329e 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -15,7 +15,11 @@ import { ActionGroupIdsOf, } from '../../../../../alerts/server'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer'; -import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { + createMetricThresholdExecutor, + FIRED_ACTIONS, + WARNING_ACTIONS, +} from './metric_threshold_executor'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; import { InfraBackendLibs } from '../../infra_types'; import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; @@ -37,7 +41,7 @@ export type MetricThresholdAlertType = AlertType< Record, AlertInstanceState, AlertInstanceContext, - ActionGroupIdsOf + ActionGroupIdsOf >; export type MetricThresholdAlertExecutorOptions = AlertExecutorOptions< /** @@ -47,7 +51,7 @@ export type MetricThresholdAlertExecutorOptions = AlertExecutorOptions< Record, AlertInstanceState, AlertInstanceContext, - ActionGroupIdsOf + ActionGroupIdsOf >; export function registerMetricThresholdAlertType(libs: InfraBackendLibs): MetricThresholdAlertType { @@ -56,6 +60,8 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs): Metric comparator: oneOfLiterals(Object.values(Comparator)), timeUnit: schema.string(), timeSize: schema.number(), + warningThreshold: schema.maybe(schema.arrayOf(schema.number())), + warningComparator: schema.maybe(oneOfLiterals(Object.values(Comparator))), }; const nonCountCriterion = schema.object({ @@ -92,7 +98,7 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs): Metric ), }, defaultActionGroupId: FIRED_ACTIONS.id, - actionGroups: [FIRED_ACTIONS], + actionGroups: [FIRED_ACTIONS, WARNING_ACTIONS], minimumLicenseRequired: 'basic', executor: createMetricThresholdExecutor(libs), actionVariables: { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index f876e40d9cd1f..37f21022f183d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -29,6 +29,8 @@ interface BaseMetricExpressionParams { sourceId?: string; threshold: number[]; comparator: Comparator; + warningComparator?: Comparator; + warningThreshold?: number[]; } interface NonCountMetricExpressionParams extends BaseMetricExpressionParams { diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index ba16221108958..cc2cf4092520a 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { PreviewResult } from '../../lib/alerting/common/types'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, @@ -65,29 +66,9 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) alertOnNoData, }); - const numberOfGroups = previewResult.length; - const resultTotals = previewResult.reduce( - (totals, [firedResult, noDataResult, errorResult, notifications]) => { - return { - ...totals, - fired: totals.fired + firedResult, - noData: totals.noData + noDataResult, - error: totals.error + errorResult, - notifications: totals.notifications + notifications, - }; - }, - { - fired: 0, - noData: 0, - error: 0, - notifications: 0, - } - ); + const payload = processPreviewResults(previewResult); return response.ok({ - body: alertPreviewSuccessResponsePayloadRT.encode({ - numberOfGroups, - resultTotals, - }), + body: alertPreviewSuccessResponsePayloadRT.encode(payload), }); } case METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID: { @@ -102,30 +83,10 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) alertOnNoData, }); - const numberOfGroups = previewResult.length; - const resultTotals = previewResult.reduce( - (totals, [firedResult, noDataResult, errorResult, notifications]) => { - return { - ...totals, - fired: totals.fired + firedResult, - noData: totals.noData + noDataResult, - error: totals.error + errorResult, - notifications: totals.notifications + notifications, - }; - }, - { - fired: 0, - noData: 0, - error: 0, - notifications: 0, - } - ); + const payload = processPreviewResults(previewResult); return response.ok({ - body: alertPreviewSuccessResponsePayloadRT.encode({ - numberOfGroups, - resultTotals, - }), + body: alertPreviewSuccessResponsePayloadRT.encode(payload), }); } default: @@ -150,3 +111,27 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) }) ); }; + +const processPreviewResults = (previewResult: PreviewResult[]) => { + const numberOfGroups = previewResult.length; + const resultTotals = previewResult.reduce( + (totals, { fired, warning, noData, error, notifications }) => { + return { + ...totals, + fired: totals.fired + fired, + warning: totals.warning + warning, + noData: totals.noData + noData, + error: totals.error + error, + notifications: totals.notifications + notifications, + }; + }, + { + fired: 0, + warning: 0, + noData: 0, + error: 0, + notifications: 0, + } + ); + return { numberOfGroups, resultTotals }; +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6658671b84682..5f34304373d25 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10184,10 +10184,6 @@ "xpack.infra.metrics.alertFlyout.alertPreviewError": "このアラート条件をプレビューするときにエラーが発生しました", "xpack.infra.metrics.alertFlyout.alertPreviewErrorDesc": "しばらくたってから再試行するか、詳細を確認してください。", "xpack.infra.metrics.alertFlyout.alertPreviewErrorResult": "一部のデータを評価するときにエラーが発生しました。", - "xpack.infra.metrics.alertFlyout.alertPreviewGroupsAcross": "すべてを対象にする", - "xpack.infra.metrics.alertFlyout.alertPreviewNoDataResult": "データなしの件数:{boldedResultsNumber}", - "xpack.infra.metrics.alertFlyout.alertPreviewNoDataResultNumber": "{noData, plural, other {件の結果がありました}}", - "xpack.infra.metrics.alertFlyout.alertPreviewResult": "{firedTimes} 回発生しました", "xpack.infra.metrics.alertFlyout.alertPreviewTotalNotifications": "結果として、このアラートは、「{alertThrottle}」に関して選択した[通知間隔]設定に基づいて{notifications}を送信しました。", "xpack.infra.metrics.alertFlyout.alertPreviewTotalNotificationsNumber": "{notifs, plural, other {#通知}}", "xpack.infra.metrics.alertFlyout.conditions": "条件", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9602583e8d215..2fb2b16b9e904 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10211,12 +10211,7 @@ "xpack.infra.metrics.alertFlyout.alertPreviewError": "尝试预览此告警条件时发生错误", "xpack.infra.metrics.alertFlyout.alertPreviewErrorDesc": "请稍后重试或查看详情了解更多信息。", "xpack.infra.metrics.alertFlyout.alertPreviewErrorResult": "尝试评估部分数据时发生错误。", - "xpack.infra.metrics.alertFlyout.alertPreviewGroups": "{numberOfGroups, plural,other {# 个 {groupName}}}", - "xpack.infra.metrics.alertFlyout.alertPreviewGroupsAcross": "在", - "xpack.infra.metrics.alertFlyout.alertPreviewNoDataResult": "存在 {boldedResultsNumber}无数据结果。", - "xpack.infra.metrics.alertFlyout.alertPreviewNoDataResultNumber": "{noData, plural, other {# 个结果}}", - "xpack.infra.metrics.alertFlyout.alertPreviewResult": "有 {firedTimes}", - "xpack.infra.metrics.alertFlyout.alertPreviewResultLookback": "在过去 {lookback} 满足此告警的条件。", + "xpack.infra.metrics.alertFlyout.alertPreviewGroups": "在 {numberOfGroups, plural,other {# 个 {groupName}}}", "xpack.infra.metrics.alertFlyout.alertPreviewTotalNotifications": "因此,此告警将根据“{alertThrottle}”的选定“通知频率”设置发送{notifications}。", "xpack.infra.metrics.alertFlyout.alertPreviewTotalNotificationsNumber": "{notifs, plural, other {# 个通知}}", "xpack.infra.metrics.alertFlyout.conditions": "条件",