diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index a6184080cb7746..0c1e5090def914 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -5,6 +5,7 @@ */ import * as rt from 'io-ts'; +import { ItemTypeRT } from '../../inventory_models/types'; // TODO: Have threshold and inventory alerts import these types from this file instead of from their // local directories @@ -39,7 +40,16 @@ const baseAlertRequestParamsRT = rt.intersection([ sourceId: rt.string, }), rt.type({ - lookback: rt.union([rt.literal('h'), rt.literal('d'), rt.literal('w'), rt.literal('M')]), + lookback: rt.union([ + rt.literal('ms'), + rt.literal('s'), + rt.literal('m'), + rt.literal('h'), + rt.literal('d'), + rt.literal('w'), + rt.literal('M'), + rt.literal('y'), + ]), criteria: rt.array(rt.any), alertInterval: rt.string, }), @@ -61,10 +71,13 @@ export type MetricThresholdAlertPreviewRequestParams = rt.TypeOf< const inventoryAlertPreviewRequestParamsRT = rt.intersection([ baseAlertRequestParamsRT, rt.type({ - nodeType: rt.string, + nodeType: ItemTypeRT, alertType: rt.literal(METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID), }), ]); +export type InventoryAlertPreviewRequestParams = rt.TypeOf< + typeof inventoryAlertPreviewRequestParamsRT +>; export const alertPreviewRequestParamsRT = rt.union([ metricThresholdAlertPreviewRequestParamsRT, @@ -80,3 +93,6 @@ export const alertPreviewSuccessResponsePayloadRT = rt.type({ tooManyBuckets: rt.number, }), }); +export type AlertPreviewSuccessResponsePayload = rt.TypeOf< + typeof alertPreviewSuccessResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/public/alerting/common/get_alert_preview.ts b/x-pack/plugins/infra/public/alerting/common/get_alert_preview.ts new file mode 100644 index 00000000000000..0db1cd57e093f1 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/common/get_alert_preview.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { HttpSetup } from 'src/core/public'; +import { + INFRA_ALERT_PREVIEW_PATH, + METRIC_THRESHOLD_ALERT_TYPE_ID, + METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + alertPreviewRequestParamsRT, + alertPreviewSuccessResponsePayloadRT, +} from '../../../common/alerting/metrics'; + +async function getAlertPreview({ + fetch, + params, + alertType, +}: { + fetch: HttpSetup['fetch']; + params: rt.TypeOf; + alertType: + | typeof METRIC_THRESHOLD_ALERT_TYPE_ID + | typeof METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID; +}): Promise> { + return await fetch(`${INFRA_ALERT_PREVIEW_PATH}`, { + method: 'POST', + body: JSON.stringify({ + ...params, + alertType, + }), + }); +} + +export const getMetricThresholdAlertPreview = ({ + fetch, + params, +}: { + fetch: HttpSetup['fetch']; + params: rt.TypeOf; +}) => getAlertPreview({ fetch, params, alertType: METRIC_THRESHOLD_ALERT_TYPE_ID }); + +export const getInventoryAlertPreview = ({ + fetch, + params, +}: { + fetch: HttpSetup['fetch']; + params: rt.TypeOf; +}) => getAlertPreview({ fetch, params, alertType: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID }); diff --git a/x-pack/plugins/infra/public/alerting/common/index.ts b/x-pack/plugins/infra/public/alerting/common/index.ts new file mode 100644 index 00000000000000..33f9c856e71666 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/common/index.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from './get_alert_preview'; + +export const previewOptions = [ + { + value: 'h', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastHourLabel', { + defaultMessage: 'Last hour', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.hourLabel', { + defaultMessage: 'hour', + }), + }, + { + value: 'd', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastDayLabel', { + defaultMessage: 'Last day', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.dayLabel', { + defaultMessage: 'day', + }), + }, + { + value: 'w', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastWeekLabel', { + defaultMessage: 'Last week', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.weekLabel', { + defaultMessage: 'week', + }), + }, + { + value: 'M', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastMonthLabel', { + defaultMessage: 'Last month', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.monthLabel', { + defaultMessage: 'month', + }), + }, +]; + +export const firedTimeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTime', { + defaultMessage: 'time', +}); +export const firedTimesLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTimes', { + defaultMessage: 'times', +}); diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/alert_dropdown.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx similarity index 73% rename from x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index ce14897991e60b..ef73d6ff96e412 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { debounce } from 'lodash'; +import { debounce, pick } from 'lodash'; +import { Unit } from '@elastic/datemath'; import React, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react'; import { EuiFlexGroup, @@ -15,9 +16,20 @@ import { EuiFormRow, EuiButtonEmpty, EuiFieldSearch, + EuiSelect, + EuiButton, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { + previewOptions, + firedTimeLabel, + firedTimesLabel, + getInventoryAlertPreview as getAlertPreview, +} from '../../../alerting/common'; +import { AlertPreviewSuccessResponsePayload } from '../../../../common/alerting/metrics/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getIntervalInSeconds } from '../../../../server/utils/get_interval_in_seconds'; import { Comparator, // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -52,6 +64,8 @@ import { NodeTypeExpression } from './node_type'; import { InfraWaffleMapOptions } from '../../../lib/lib'; import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; +import { validateMetricThreshold } from './validation'; + const FILTER_TYPING_DEBOUNCE_MS = 500; interface AlertContextMeta { @@ -65,18 +79,16 @@ interface Props { alertParams: { criteria: InventoryMetricConditions[]; nodeType: InventoryItemType; - groupBy?: string; filterQuery?: string; filterQueryText?: string; sourceId?: string; }; + alertInterval: string; alertsContext: AlertsContextValue; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; } -type TimeUnit = 's' | 'm' | 'h' | 'd'; - const defaultExpression = { metric: 'cpu' as SnapshotMetricType, comparator: Comparator.GT, @@ -86,7 +98,7 @@ const defaultExpression = { } as InventoryMetricConditions; export const Expressions: React.FC = (props) => { - const { setAlertParams, alertParams, errors, alertsContext } = props; + const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -94,7 +106,32 @@ export const Expressions: React.FC = (props) => { toastWarning: alertsContext.toastNotifications.addWarning, }); const [timeSize, setTimeSize] = useState(1); - const [timeUnit, setTimeUnit] = useState('m'); + const [timeUnit, setTimeUnit] = useState('m'); + + const [previewLookbackInterval, setPreviewLookbackInterval] = useState('h'); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(false); + const [previewResult, setPreviewResult] = useState( + null + ); + + const previewIntervalError = useMemo(() => { + const intervalInSeconds = getIntervalInSeconds(alertInterval); + const lookbackInSeconds = getIntervalInSeconds(`1${previewLookbackInterval}`); + if (intervalInSeconds >= lookbackInSeconds) { + return true; + } + return false; + }, [previewLookbackInterval, alertInterval]); + + const isPreviewDisabled = useMemo(() => { + if (previewIntervalError) return true; + const validationResult = validateMetricThreshold({ criteria: alertParams.criteria } as any); + const hasValidationErrors = Object.values(validationResult.errors).some((result) => + Object.values(result).some((arr) => Array.isArray(arr) && arr.length) + ); + return hasValidationErrors; + }, [alertParams.criteria, previewIntervalError]); const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ createDerivedIndexPattern, @@ -173,7 +210,7 @@ export const Expressions: React.FC = (props) => { ...c, timeUnit: tu, })); - setTimeUnit(tu as TimeUnit); + setTimeUnit(tu as Unit); setAlertParams('criteria', criteria); }, [alertParams.criteria, setAlertParams] @@ -216,6 +253,33 @@ export const Expressions: React.FC = (props) => { } }, [alertsContext.metadata, derivedIndexPattern, setAlertParams]); + const onSelectPreviewLookbackInterval = useCallback((e) => { + setPreviewLookbackInterval(e.target.value); + setPreviewResult(null); + }, []); + + const onClickPreview = useCallback(async () => { + setIsPreviewLoading(true); + setPreviewResult(null); + setPreviewError(false); + try { + const result = await getAlertPreview({ + fetch: alertsContext.http.fetch, + params: { + ...pick(alertParams, 'criteria', 'nodeType'), + sourceId: alertParams.sourceId, + lookback: previewLookbackInterval as Unit, + alertInterval, + }, + }); + setPreviewResult(result); + } catch (e) { + setPreviewError(true); + } finally { + setIsPreviewLoading(false); + } + }, [alertParams, alertInterval, alertsContext, previewLookbackInterval]); + useEffect(() => { const md = alertsContext.metadata; if (!alertParams.nodeType) { @@ -332,6 +396,91 @@ export const Expressions: React.FC = (props) => { + + <> + + + + + + + {i18n.translate('xpack.infra.metrics.alertFlyout.testAlertTrigger', { + defaultMessage: 'Test alert trigger', + })} + + + + + {previewResult && ( + <> + + + {previewResult.resultTotals.fired}, + lookback: previewOptions.find((e) => e.value === previewLookbackInterval) + ?.shortText, + }} + />{' '} + {previewResult.numberOfGroups}, + groupName: alertParams.nodeType, + plural: previewResult.numberOfGroups !== 1 ? 's' : '', + }} + /> + + + )} + {previewIntervalError && ( + <> + + + check every, + }} + /> + + + )} + {previewError && ( + <> + + + + + + )} + + + ); }; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/metric.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/metric.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/node_type.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/node_type.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/node_type.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/node_type.tsx diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts b/x-pack/plugins/infra/public/alerting/inventory/index.ts similarity index 73% rename from x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts rename to x-pack/plugins/infra/public/alerting/inventory/index.ts index 0cb564ec2194e2..7503e5673fcd96 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts +++ b/x-pack/plugins/infra/public/alerting/inventory/index.ts @@ -6,19 +6,20 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; -import { validateMetricThreshold } from './validation'; +import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../server/lib/alerting/inventory_metric_threshold/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../triggers_actions_ui/public/types'; +import { validateMetricThreshold } from './components/validation'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; -export function getInventoryMetricAlertType(): AlertTypeModel { +export function createInventoryMetricAlertType(): AlertTypeModel { return { id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, name: i18n.translate('xpack.infra.metrics.inventory.alertFlyout.alertName', { defaultMessage: 'Inventory', }), iconClass: 'bell', - alertParamsExpression: React.lazy(() => import('./expression')), + alertParamsExpression: React.lazy(() => import('./components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( 'xpack.infra.metrics.alerting.inventory.threshold.defaultActionMessage', diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index febf849ccc9438..3c3351f4ddd76d 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -5,8 +5,8 @@ */ import { debounce, pick } from 'lodash'; +import { Unit } from '@elastic/datemath'; import * as rt from 'io-ts'; -import { HttpSetup } from 'src/core/public'; import React, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react'; import { EuiSpacer, @@ -24,15 +24,18 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { + previewOptions, + firedTimeLabel, + firedTimesLabel, + getMetricThresholdAlertPreview as getAlertPreview, +} from '../../common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getIntervalInSeconds } from '../../../../server/utils/get_interval_in_seconds'; import { Comparator, Aggregators, - INFRA_ALERT_PREVIEW_PATH, - alertPreviewRequestParamsRT, alertPreviewSuccessResponsePayloadRT, - METRIC_THRESHOLD_ALERT_TYPE_ID, } from '../../../../common/alerting/metrics'; import { ForLastExpression, @@ -79,22 +82,6 @@ const defaultExpression = { timeUnit: 'm', } as MetricExpression; -async function getAlertPreview({ - fetch, - params, -}: { - fetch: HttpSetup['fetch']; - params: rt.TypeOf; -}): Promise> { - return await fetch(`${INFRA_ALERT_PREVIEW_PATH}`, { - method: 'POST', - body: JSON.stringify({ - ...params, - alertType: METRIC_THRESHOLD_ALERT_TYPE_ID, - }), - }); -} - export const Expressions: React.FC = (props) => { const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ @@ -275,7 +262,7 @@ export const Expressions: React.FC = (props) => { params: { ...pick(alertParams, 'criteria', 'groupBy', 'filterQuery'), sourceId: alertParams.sourceId, - lookback: previewLookbackInterval as 'h' | 'd' | 'w' | 'M', + lookback: previewLookbackInterval as Unit, alertInterval, }, }); @@ -319,11 +306,12 @@ export const Expressions: React.FC = (props) => { }, [previewLookbackInterval, alertInterval]); const isPreviewDisabled = useMemo(() => { + if (previewIntervalError) return true; const validationResult = validateMetricThreshold({ criteria: alertParams.criteria } as any); const hasValidationErrors = Object.values(validationResult.errors).some((result) => Object.values(result).some((arr) => Array.isArray(arr) && arr.length) ); - return hasValidationErrors || previewIntervalError; + return hasValidationErrors; }, [alertParams.criteria, previewIntervalError]); return ( @@ -600,52 +588,6 @@ export const Expressions: React.FC = (props) => { ); }; -const previewOptions = [ - { - value: 'h', - text: i18n.translate('xpack.infra.metrics.alertFlyout.lastHourLabel', { - defaultMessage: 'Last hour', - }), - shortText: i18n.translate('xpack.infra.metrics.alertFlyout.hourLabel', { - defaultMessage: 'hour', - }), - }, - { - value: 'd', - text: i18n.translate('xpack.infra.metrics.alertFlyout.lastDayLabel', { - defaultMessage: 'Last day', - }), - shortText: i18n.translate('xpack.infra.metrics.alertFlyout.dayLabel', { - defaultMessage: 'day', - }), - }, - { - value: 'w', - text: i18n.translate('xpack.infra.metrics.alertFlyout.lastWeekLabel', { - defaultMessage: 'Last week', - }), - shortText: i18n.translate('xpack.infra.metrics.alertFlyout.weekLabel', { - defaultMessage: 'week', - }), - }, - { - value: 'M', - text: i18n.translate('xpack.infra.metrics.alertFlyout.lastMonthLabel', { - defaultMessage: 'Last month', - }), - shortText: i18n.translate('xpack.infra.metrics.alertFlyout.monthLabel', { - defaultMessage: 'month', - }), - }, -]; - -const firedTimeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTime', { - defaultMessage: 'time', -}); -const firedTimesLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTimes', { - defaultMessage: 'times', -}); - // required for dynamic import // eslint-disable-next-line import/no-default-export export default Expressions; diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 05296fbf6b0a32..ab7f41e3066b8c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -29,7 +29,7 @@ import { WaffleOptionsProvider } from './inventory_view/hooks/use_waffle_options import { WaffleTimeProvider } from './inventory_view/hooks/use_waffle_time'; import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters'; -import { InventoryAlertDropdown } from '../../components/alerting/inventory/alert_dropdown'; +import { InventoryAlertDropdown } from '../../alerting/inventory/components/alert_dropdown'; import { MetricsAlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown'; const ADD_DATA_LABEL = i18n.translate('xpack.infra.metricsHeaderAddDataButtonLabel', { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index 3441b6bf2c1b9e..d9132615213836 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo, useState } from 'react'; -import { AlertFlyout } from '../../../../../components/alerting/inventory/alert_flyout'; +import { AlertFlyout } from '../../../../../alerting/inventory/components/alert_flyout'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib'; import { getNodeDetailUrl, getNodeLogsUrl } from '../../../../link_to'; import { createUptimeLink } from '../../lib/create_uptime_link'; diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index b3765db43335a3..496e788efc060f 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -13,7 +13,7 @@ import { } from 'kibana/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { createMetricThresholdAlertType } from './alerting/metric_threshold'; -import { getInventoryMetricAlertType } from './components/alerting/inventory/metric_inventory_threshold_alert_type'; +import { createInventoryMetricAlertType } from './alerting/inventory'; import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type'; import { registerStartSingleton } from './legacy_singletons'; import { registerFeatures } from './register_feature'; @@ -29,7 +29,7 @@ export class Plugin setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { registerFeatures(pluginsSetup.home); - pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getInventoryMetricAlertType()); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createInventoryMetricAlertType()); pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getLogsAlertType()); pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createMetricThresholdAlertType()); 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 new file mode 100644 index 00000000000000..c55f50e229b698 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { mapValues, last } from 'lodash'; +import moment from 'moment'; +import { + InfraDatabaseSearchResponse, + CallWithRequestParams, +} from '../../adapters/framework/adapter_types'; +import { Comparator, InventoryMetricConditions } from './types'; +import { AlertServices } from '../../../../../alerts/server'; +import { InfraSnapshot } from '../../snapshot'; +import { parseFilterQuery } from '../../../utils/serialized_query'; +import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; +import { InfraSourceConfiguration } from '../../sources'; + +interface ConditionResult { + shouldFire: boolean | boolean[]; + currentValue?: number | null; + metric: string; + isNoData: boolean; + isError: boolean; +} + +export const evaluateCondition = async ( + condition: InventoryMetricConditions, + nodeType: InventoryItemType, + sourceConfiguration: InfraSourceConfiguration, + callCluster: AlertServices['callCluster'], + filterQuery?: string, + lookbackSize?: number +): Promise> => { + const { comparator, metric } = condition; + let { threshold } = condition; + + const timerange = { + to: Date.now(), + from: moment().subtract(condition.timeSize, condition.timeUnit).toDate().getTime(), + interval: condition.timeUnit, + } as InfraTimerangeInput; + if (lookbackSize) { + timerange.lookbackSize = lookbackSize; + } + + const currentValues = await getData( + callCluster, + nodeType, + metric, + timerange, + sourceConfiguration, + filterQuery + ); + + threshold = threshold.map((n) => convertMetricValue(metric, n)); + + const comparisonFunction = comparatorMap[comparator]; + + return mapValues(currentValues, (value) => ({ + shouldFire: + value !== undefined && + value !== null && + (Array.isArray(value) + ? value.map((v) => comparisonFunction(Number(v), threshold)) + : comparisonFunction(value, threshold)), + metric, + isNoData: value === null, + isError: value === undefined, + ...(!Array.isArray(value) ? { currentValue: value } : {}), + })); +}; + +const getData = async ( + callCluster: AlertServices['callCluster'], + nodeType: InventoryItemType, + metric: SnapshotMetricType, + timerange: InfraTimerangeInput, + sourceConfiguration: InfraSourceConfiguration, + filterQuery?: string +) => { + const snapshot = new InfraSnapshot(); + const esClient = ( + options: CallWithRequestParams + ): Promise> => callCluster('search', options); + + const options = { + filterQuery: parseFilterQuery(filterQuery), + nodeType, + groupBy: [], + sourceConfiguration, + metric: { type: metric }, + timerange, + includeTimeseries: Boolean(timerange.lookbackSize), + }; + + const { nodes } = await snapshot.getNodes(esClient, options); + + return nodes.reduce((acc, n) => { + const nodePathItem = last(n.path); + if (n.metric?.value && n.metric?.timeseries) { + const { timeseries } = n.metric; + const values = timeseries.rows.map((row) => row.metric_0) as Array; + acc[nodePathItem.label] = values; + } else { + acc[nodePathItem.label] = n.metric && n.metric.value; + } + return acc; + }, {} as Record | undefined | null>); +}; + +const comparatorMap = { + [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => + value >= Math.min(a, b) && value <= Math.max(a, b), + // `threshold` is always an array of numbers in case the BETWEEN comparator is + // used; all other compartors will just destructure the first value in the array + [Comparator.GT]: (a: number, [b]: number[]) => a > b, + [Comparator.LT]: (a: number, [b]: number[]) => a < b, + [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b, + [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, + [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, +}; + +// Some metrics in the UI are in a different unit that what we store in ES. +const convertMetricValue = (metric: SnapshotMetricType, value: number) => { + if (converters[metric]) { + return converters[metric](value); + } else { + return value; + } +}; +const converters: Record number> = { + cpu: (n) => Number(n) / 100, + memory: (n) => Number(n) / 100, +}; 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 5a34a6665e781d..99e653b2d67894 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 @@ -3,27 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { mapValues, last, get } from 'lodash'; +import { first, get } from 'lodash'; import { i18n } from '@kbn/i18n'; -import moment from 'moment'; -import { - InfraDatabaseSearchResponse, - CallWithRequestParams, -} from '../../adapters/framework/adapter_types'; -import { Comparator, AlertStates, InventoryMetricConditions } from './types'; -import { AlertServices, AlertExecutorOptions } from '../../../../../alerts/server'; -import { InfraSnapshot } from '../../snapshot'; -import { parseFilterQuery } from '../../../utils/serialized_query'; +import { AlertStates, InventoryMetricConditions } from './types'; +import { AlertExecutorOptions } from '../../../../../alerts/server'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; -import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; -import { InfraSourceConfiguration } from '../../sources'; import { InfraBackendLibs } from '../../infra_types'; import { METRIC_FORMATTERS } from '../../../../common/formatters/snapshot_metric_formats'; import { createFormatter } from '../../../../common/formatters'; +import { evaluateCondition } from './evaluate_condition'; interface InventoryMetricThresholdParams { criteria: InventoryMetricConditions[]; - groupBy: string | undefined; filterQuery: string | undefined; nodeType: InventoryItemType; sourceId?: string; @@ -41,11 +32,13 @@ export const createInventoryMetricThresholdExecutor = ( ); const results = await Promise.all( - criteria.map((c) => evaluateCondtion(c, nodeType, source.configuration, services, filterQuery)) + criteria.map((c) => + evaluateCondition(c, nodeType, source.configuration, services.callCluster, filterQuery) + ) ); - const invenotryItems = Object.keys(results[0]); - for (const item of invenotryItems) { + const inventoryItems = Object.keys(first(results)); + for (const item of inventoryItems) { const alertInstance = services.alertInstanceFactory(`${alertId}-${item}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = results.every((result) => result[item].shouldFire); @@ -79,93 +72,6 @@ export const createInventoryMetricThresholdExecutor = ( } }; -interface ConditionResult { - shouldFire: boolean; - currentValue?: number | null; - isNoData: boolean; - isError: boolean; -} - -const evaluateCondtion = async ( - condition: InventoryMetricConditions, - nodeType: InventoryItemType, - sourceConfiguration: InfraSourceConfiguration, - services: AlertServices, - filterQuery?: string -): Promise> => { - const { comparator, metric } = condition; - let { threshold } = condition; - - const currentValues = await getData( - services, - nodeType, - metric, - { - to: Date.now(), - from: moment().subtract(condition.timeSize, condition.timeUnit).toDate().getTime(), - interval: condition.timeUnit, - }, - sourceConfiguration, - filterQuery - ); - - threshold = threshold.map((n) => convertMetricValue(metric, n)); - - const comparisonFunction = comparatorMap[comparator]; - - return mapValues(currentValues, (value) => ({ - shouldFire: value !== undefined && value !== null && comparisonFunction(value, threshold), - metric, - currentValue: value, - isNoData: value === null, - isError: value === undefined, - })); -}; - -const getData = async ( - services: AlertServices, - nodeType: InventoryItemType, - metric: SnapshotMetricType, - timerange: InfraTimerangeInput, - sourceConfiguration: InfraSourceConfiguration, - filterQuery?: string -) => { - const snapshot = new InfraSnapshot(); - const esClient = ( - options: CallWithRequestParams - ): Promise> => - services.callCluster('search', options); - - const options = { - filterQuery: parseFilterQuery(filterQuery), - nodeType, - groupBy: [], - sourceConfiguration, - metric: { type: metric }, - timerange, - }; - - const { nodes } = await snapshot.getNodes(esClient, options); - - return nodes.reduce((acc, n) => { - const nodePathItem = last(n.path); - acc[nodePathItem.label] = n.metric && n.metric.value; - return acc; - }, {} as Record); -}; - -const comparatorMap = { - [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => - value >= Math.min(a, b) && value <= Math.max(a, b), - // `threshold` is always an array of numbers in case the BETWEEN comparator is - // used; all other compartors will just destructure the first value in the array - [Comparator.GT]: (a: number, [b]: number[]) => a > b, - [Comparator.LT]: (a: number, [b]: number[]) => a < b, - [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b, - [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, - [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, -}; - const mapToConditionsLookup = ( list: any[], mapFn: (value: any, index: number, array: any[]) => unknown @@ -184,19 +90,6 @@ export const FIRED_ACTIONS = { }), }; -// Some metrics in the UI are in a different unit that what we store in ES. -const convertMetricValue = (metric: SnapshotMetricType, value: number) => { - if (converters[metric]) { - return converters[metric](value); - } else { - return value; - } -}; -const converters: Record number> = { - cpu: (n) => Number(n) / 100, - memory: (n) => Number(n) / 100, -}; - const formatMetric = (metric: SnapshotMetricType, value: number) => { // if (SnapshotCustomMetricInputRT.is(metric)) { // const formatter = createFormatterForMetric(metric); 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 new file mode 100644 index 00000000000000..6e8c624e61c49a --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Unit } from '@elastic/datemath'; +import { first } from 'lodash'; +import { InventoryMetricConditions } from './types'; +import { IScopedClusterClient } from '../../../../../../../src/core/server'; +import { InfraSource } from '../../../../common/http_api/source_api'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; +import { evaluateCondition } from './evaluate_condition'; + +interface InventoryMetricThresholdParams { + criteria: InventoryMetricConditions[]; + filterQuery: string | undefined; + nodeType: InventoryItemType; + sourceId?: string; +} + +interface PreviewInventoryMetricThresholdAlertParams { + callCluster: IScopedClusterClient['callAsCurrentUser']; + params: InventoryMetricThresholdParams; + config: InfraSource['configuration']; + lookback: Unit; + alertInterval: string; +} + +export const previewInventoryMetricThresholdAlert = async ({ + callCluster, + params, + config, + lookback, + alertInterval, +}: PreviewInventoryMetricThresholdAlertParams) => { + const { criteria, filterQuery, nodeType } = params as InventoryMetricThresholdParams; + + const { timeSize, timeUnit } = criteria[0]; + const bucketInterval = `${timeSize}${timeUnit}`; + const bucketIntervalInSeconds = getIntervalInSeconds(bucketInterval); + + const lookbackInterval = `1${lookback}`; + const lookbackIntervalInSeconds = getIntervalInSeconds(lookbackInterval); + const lookbackSize = Math.ceil(lookbackIntervalInSeconds / bucketIntervalInSeconds); + + const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); + const alertResultsPerExecution = alertIntervalInSeconds / bucketIntervalInSeconds; + + const results = await Promise.all( + criteria.map((c) => + evaluateCondition(c, nodeType, config, callCluster, filterQuery, lookbackSize) + ) + ); + + const inventoryItems = Object.keys(first(results)); + const previewResults = inventoryItems.map((item) => { + const isNoData = results.some((result) => result[item].isNoData); + if (isNoData) { + return null; + } + const isError = results.some((result) => result[item].isError); + if (isError) { + return undefined; + } + + const numberOfResultBuckets = lookbackSize; + const numberOfExecutionBuckets = Math.floor(numberOfResultBuckets / alertResultsPerExecution); + return [...Array(numberOfExecutionBuckets)].reduce( + (totalFired, _, i) => + totalFired + + (results.every((result) => { + const shouldFire = result[item].shouldFire as boolean[]; + return shouldFire[Math.floor(i * alertResultsPerExecution)]; + }) + ? 1 + : 0), + 0 + ); + }); + + return previewResults; +}; 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 73ee1ab6b76159..ec1caad30a4d73 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 @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { Unit } from '@elastic/datemath'; import { SnapshotMetricType } from '../../../../common/inventory_models/types'; export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; @@ -23,12 +24,10 @@ export enum AlertStates { ERROR, } -export type TimeUnit = 's' | 'm' | 'h' | 'd'; - export interface InventoryMetricConditions { metric: SnapshotMetricType; timeSize: number; - timeUnit: TimeUnit; + timeUnit: Unit; sourceId?: string; threshold: number[]; comparator: Comparator; 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 7aa8367f7678ca..52637d52175a42 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 @@ -5,6 +5,7 @@ */ import { first, zip } from 'lodash'; +import { Unit } from '@elastic/datemath'; import { TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, isTooManyBucketsPreviewException, @@ -25,7 +26,7 @@ interface PreviewMetricThresholdAlertParams { filterQuery: string | undefined; }; config: InfraSource['configuration']; - lookback: 'h' | 'd' | 'w' | 'M'; + lookback: Unit; alertInterval: string; end?: number; overrideLookbackIntervalInSeconds?: number; diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index f4eed041481f6a..d11425a4f4cb07 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -12,8 +12,10 @@ import { alertPreviewRequestParamsRT, alertPreviewSuccessResponsePayloadRT, MetricThresholdAlertPreviewRequestParams, + InventoryAlertPreviewRequestParams, } from '../../../common/alerting/metrics'; import { createValidationFunction } from '../../../common/runtime_types'; +import { previewInventoryMetricThresholdAlert } from '../../lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert'; import { previewMetricThresholdAlert } from '../../lib/alerting/metric_threshold/preview_metric_threshold_alert'; import { InfraBackendLibs } from '../../lib/infra_types'; @@ -76,8 +78,35 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) }); } case METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID: { - // TODO: Add inventory preview functionality - return response.ok({}); + const { nodeType } = request.body as InventoryAlertPreviewRequestParams; + const previewResult = await previewInventoryMetricThresholdAlert({ + callCluster, + params: { criteria, filterQuery, nodeType }, + lookback, + config: source.configuration, + alertInterval, + }); + + const numberOfGroups = previewResult.length; + const resultTotals = previewResult.reduce( + (totals, groupResult) => { + if (groupResult === null) return { ...totals, noData: totals.noData + 1 }; + if (isNaN(groupResult)) return { ...totals, error: totals.error + 1 }; + return { ...totals, fired: totals.fired + groupResult }; + }, + { + fired: 0, + noData: 0, + error: 0, + } + ); + + return response.ok({ + body: alertPreviewSuccessResponsePayloadRT.encode({ + numberOfGroups, + resultTotals, + }), + }); } default: throw new Error('Unknown alert type');