[Metrics UI] Add inventory alert preview (#68909)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Zacqary Adam Xeper 2020-06-23 18:34:17 -05:00 committed by GitHub
parent e3d01bf450
commit 6a016d0b57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 563 additions and 208 deletions

View file

@ -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
>;

View file

@ -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<typeof alertPreviewRequestParamsRT>;
alertType:
| typeof METRIC_THRESHOLD_ALERT_TYPE_ID
| typeof METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID;
}): Promise<rt.TypeOf<typeof alertPreviewSuccessResponsePayloadRT>> {
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<typeof alertPreviewRequestParamsRT>;
}) => getAlertPreview({ fetch, params, alertType: METRIC_THRESHOLD_ALERT_TYPE_ID });
export const getInventoryAlertPreview = ({
fetch,
params,
}: {
fetch: HttpSetup['fetch'];
params: rt.TypeOf<typeof alertPreviewRequestParamsRT>;
}) => getAlertPreview({ fetch, params, alertType: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID });

View file

@ -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',
});

View file

@ -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<AlertContextMeta>;
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> = (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> = (props) => {
toastWarning: alertsContext.toastNotifications.addWarning,
});
const [timeSize, setTimeSize] = useState<number | undefined>(1);
const [timeUnit, setTimeUnit] = useState<TimeUnit>('m');
const [timeUnit, setTimeUnit] = useState<Unit>('m');
const [previewLookbackInterval, setPreviewLookbackInterval] = useState<string>('h');
const [isPreviewLoading, setIsPreviewLoading] = useState<boolean>(false);
const [previewError, setPreviewError] = useState<boolean>(false);
const [previewResult, setPreviewResult] = useState<AlertPreviewSuccessResponsePayload | null>(
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> = (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> = (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> = (props) => {
</EuiFormRow>
<EuiSpacer size={'m'} />
<EuiFormRow
label={i18n.translate('xpack.infra.metrics.alertFlyout.previewLabel', {
defaultMessage: 'Preview',
})}
fullWidth
compressed
>
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiSelect
id="selectPreviewLookbackInterval"
value={previewLookbackInterval}
onChange={onSelectPreviewLookbackInterval}
options={previewOptions}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
isLoading={isPreviewLoading}
isDisabled={isPreviewDisabled}
onClick={onClickPreview}
>
{i18n.translate('xpack.infra.metrics.alertFlyout.testAlertTrigger', {
defaultMessage: 'Test alert trigger',
})}
</EuiButton>
</EuiFlexItem>
<EuiSpacer size={'s'} />
</EuiFlexGroup>
{previewResult && (
<>
<EuiSpacer size={'s'} />
<EuiText>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.alertPreviewResult"
defaultMessage="This alert would have fired {fired} {timeOrTimes} in the past {lookback}"
values={{
timeOrTimes:
previewResult.resultTotals.fired === 1 ? firedTimeLabel : firedTimesLabel,
fired: <strong>{previewResult.resultTotals.fired}</strong>,
lookback: previewOptions.find((e) => e.value === previewLookbackInterval)
?.shortText,
}}
/>{' '}
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.alertPreviewGroups"
defaultMessage="across {numberOfGroups} {groupName}{plural}."
values={{
numberOfGroups: <strong>{previewResult.numberOfGroups}</strong>,
groupName: alertParams.nodeType,
plural: previewResult.numberOfGroups !== 1 ? 's' : '',
}}
/>
</EuiText>
</>
)}
{previewIntervalError && (
<>
<EuiSpacer size={'s'} />
<EuiText>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.previewIntervalTooShort"
defaultMessage="Not enough data to preview. Please select a longer preview length, or increase the amount of time in the {checkEvery} field."
values={{
checkEvery: <strong>check every</strong>,
}}
/>
</EuiText>
</>
)}
{previewError && (
<>
<EuiSpacer size={'s'} />
<EuiText>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.alertPreviewError"
defaultMessage="An error occurred when trying to preview this alert trigger."
/>
</EuiText>
</>
)}
</>
</EuiFormRow>
<EuiSpacer size={'m'} />
</>
);
};

View file

@ -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',

View file

@ -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<typeof alertPreviewRequestParamsRT>;
}): Promise<rt.TypeOf<typeof alertPreviewSuccessResponsePayloadRT>> {
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> = (props) => {
const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props;
const { source, createDerivedIndexPattern } = useSourceViaHttp({
@ -275,7 +262,7 @@ export const Expressions: React.FC<Props> = (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> = (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> = (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;

View file

@ -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', {

View file

@ -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';

View file

@ -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<ClientPluginsStart, ClientStart>, 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());

View file

@ -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<Record<string, ConditionResult>> => {
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 = <Hit = {}, Aggregation = undefined>(
options: CallWithRequestParams
): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> => 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<number | null>;
acc[nodePathItem.label] = values;
} else {
acc[nodePathItem.label] = n.metric && n.metric.value;
}
return acc;
}, {} as Record<string, number | Array<number | string | null | undefined> | 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<string, (n: number) => number> = {
cpu: (n) => Number(n) / 100,
memory: (n) => Number(n) / 100,
};

View file

@ -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<Record<string, ConditionResult>> => {
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 = <Hit = {}, Aggregation = undefined>(
options: CallWithRequestParams
): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> =>
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<string, number | 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,
};
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<string, (n: number) => 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);

View file

@ -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;
};

View file

@ -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;

View file

@ -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;

View file

@ -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');