[Metrics UI] Add inventory metric threshold alerts (#64292)

* Add new inventory metric threshold alert

* Add missed file

* Fix some types

* Convert units on client and executor.

* Move formatters to common. Properly format metrics in alert messages

* Style changes

* Remove unused files

* fix test

* Update create

* Fix signature

* Remove old test. Remove unecessary import

* Pass in filter when clicking create alert from context menu

* Fix filtering

* Fix more types

* Fix tests

* Fix merge

* Fix merge

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Phillip Burch 2020-04-30 16:53:56 -05:00 committed by GitHub
parent f9c1033d41
commit 3cef8e6f30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1995 additions and 497 deletions

View file

@ -3,9 +3,9 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InfraWaffleMapDataFormat } from '../../lib/lib';
import { InfraWaffleMapDataFormat } from './types';
import { createBytesFormatter } from './bytes';
describe('createDataFormatter', () => {
it('should format bytes as bytesDecimal', () => {
const formatter = createBytesFormatter(InfraWaffleMapDataFormat.bytesDecimal);

View file

@ -3,9 +3,8 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InfraWaffleMapDataFormat } from '../../lib/lib';
import { formatNumber } from './number';
import { InfraWaffleMapDataFormat } from './types';
/**
* The labels are derived from these two Wikipedia articles.

View file

@ -5,12 +5,12 @@
*/
import Mustache from 'mustache';
import { InfraWaffleMapDataFormat } from '../../lib/lib';
import { createBytesFormatter } from './bytes';
import { formatNumber } from './number';
import { formatPercent } from './percent';
import { InventoryFormatterType } from '../../../common/inventory_models/types';
import { InventoryFormatterType } from '../inventory_models/types';
import { formatHighPercision } from './high_precision';
import { InfraWaffleMapDataFormat } from './types';
export const FORMATTERS = {
number: formatNumber,

View file

@ -0,0 +1,73 @@
/*
* 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.
*/
enum InfraFormatterType {
number = 'number',
abbreviatedNumber = 'abbreviatedNumber',
bytes = 'bytes',
bits = 'bits',
percent = 'percent',
}
interface MetricFormatter {
formatter: InfraFormatterType;
template: string;
bounds?: { min: number; max: number };
}
interface MetricFormatters {
[key: string]: MetricFormatter;
}
export const METRIC_FORMATTERS: MetricFormatters = {
['count']: { formatter: InfraFormatterType.number, template: '{{value}}' },
['cpu']: {
formatter: InfraFormatterType.percent,
template: '{{value}}',
},
['memory']: {
formatter: InfraFormatterType.percent,
template: '{{value}}',
},
['rx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' },
['tx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' },
['logRate']: {
formatter: InfraFormatterType.abbreviatedNumber,
template: '{{value}}/s',
},
['diskIOReadBytes']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}/s',
},
['diskIOWriteBytes']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}/s',
},
['s3BucketSize']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}',
},
['s3TotalRequests']: {
formatter: InfraFormatterType.abbreviatedNumber,
template: '{{value}}',
},
['s3NumberOfObjects']: {
formatter: InfraFormatterType.abbreviatedNumber,
template: '{{value}}',
},
['s3UploadBytes']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}',
},
['s3DownloadBytes']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}',
},
['sqsOldestMessage']: {
formatter: InfraFormatterType.number,
template: '{{value}} seconds',
},
};

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export enum InfraWaffleMapDataFormat {
bytesDecimal = 'bytesDecimal',
bitsDecimal = 'bitsDecimal',
abbreviatedNumber = 'abbreviatedNumber',
}

View file

@ -11,27 +11,29 @@ import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_
import { CloudToolbarItems } from '../shared/components/cloud_toolbar_items';
import { SnapshotMetricType } from '../types';
export const ec2MetricTypes: SnapshotMetricType[] = [
'cpu',
'rx',
'tx',
'diskIOReadBytes',
'diskIOWriteBytes',
];
export const ec2groupByFields = [
'cloud.availability_zone',
'cloud.machine.type',
'aws.ec2.instance.image.id',
'aws.ec2.instance.state.name',
];
export const AwsEC2ToolbarItems = (props: ToolbarProps) => {
const metricTypes: SnapshotMetricType[] = [
'cpu',
'rx',
'tx',
'diskIOReadBytes',
'diskIOWriteBytes',
];
const groupByFields = [
'cloud.availability_zone',
'cloud.machine.type',
'aws.ec2.instance.image.id',
'aws.ec2.instance.state.name',
];
return (
<>
<CloudToolbarItems {...props} />
<MetricsAndGroupByToolbarItems
{...props}
metricTypes={metricTypes}
groupByFields={groupByFields}
metricTypes={ec2MetricTypes}
groupByFields={ec2groupByFields}
/>
</>
);

View file

@ -11,26 +11,28 @@ import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_
import { CloudToolbarItems } from '../shared/components/cloud_toolbar_items';
import { SnapshotMetricType } from '../types';
export const rdsMetricTypes: SnapshotMetricType[] = [
'cpu',
'rdsConnections',
'rdsQueriesExecuted',
'rdsActiveTransactions',
'rdsLatency',
];
export const rdsGroupByFields = [
'cloud.availability_zone',
'aws.rds.db_instance.class',
'aws.rds.db_instance.status',
];
export const AwsRDSToolbarItems = (props: ToolbarProps) => {
const metricTypes: SnapshotMetricType[] = [
'cpu',
'rdsConnections',
'rdsQueriesExecuted',
'rdsActiveTransactions',
'rdsLatency',
];
const groupByFields = [
'cloud.availability_zone',
'aws.rds.db_instance.class',
'aws.rds.db_instance.status',
];
return (
<>
<CloudToolbarItems {...props} />
<MetricsAndGroupByToolbarItems
{...props}
metricTypes={metricTypes}
groupByFields={groupByFields}
metricTypes={rdsMetricTypes}
groupByFields={rdsGroupByFields}
/>
</>
);

View file

@ -11,22 +11,24 @@ import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_
import { CloudToolbarItems } from '../shared/components/cloud_toolbar_items';
import { SnapshotMetricType } from '../types';
export const s3MetricTypes: SnapshotMetricType[] = [
's3BucketSize',
's3NumberOfObjects',
's3TotalRequests',
's3DownloadBytes',
's3UploadBytes',
];
export const s3GroupByFields = ['cloud.region'];
export const AwsS3ToolbarItems = (props: ToolbarProps) => {
const metricTypes: SnapshotMetricType[] = [
's3BucketSize',
's3NumberOfObjects',
's3TotalRequests',
's3DownloadBytes',
's3UploadBytes',
];
const groupByFields = ['cloud.region'];
return (
<>
<CloudToolbarItems {...props} />
<MetricsAndGroupByToolbarItems
{...props}
metricTypes={metricTypes}
groupByFields={groupByFields}
metricTypes={s3MetricTypes}
groupByFields={s3GroupByFields}
/>
</>
);

View file

@ -11,22 +11,23 @@ import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_
import { CloudToolbarItems } from '../shared/components/cloud_toolbar_items';
import { SnapshotMetricType } from '../types';
export const sqsMetricTypes: SnapshotMetricType[] = [
'sqsMessagesVisible',
'sqsMessagesDelayed',
'sqsMessagesSent',
'sqsMessagesEmpty',
'sqsOldestMessage',
];
export const sqsGroupByFields = ['cloud.region'];
export const AwsSQSToolbarItems = (props: ToolbarProps) => {
const metricTypes: SnapshotMetricType[] = [
'sqsMessagesVisible',
'sqsMessagesDelayed',
'sqsMessagesSent',
'sqsMessagesEmpty',
'sqsOldestMessage',
];
const groupByFields = ['cloud.region'];
return (
<>
<CloudToolbarItems {...props} />
<MetricsAndGroupByToolbarItems
{...props}
metricTypes={metricTypes}
groupByFields={groupByFields}
metricTypes={sqsMetricTypes}
groupByFields={sqsGroupByFields}
/>
</>
);

View file

@ -10,21 +10,22 @@ import { ToolbarProps } from '../../../public/pages/metrics/inventory_view/compo
import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_groupby_toolbar_items';
import { SnapshotMetricType } from '../types';
export const containerMetricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx'];
export const containerGroupByFields = [
'host.name',
'cloud.availability_zone',
'cloud.machine.type',
'cloud.project.id',
'cloud.provider',
'service.type',
];
export const ContainerToolbarItems = (props: ToolbarProps) => {
const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx'];
const groupByFields = [
'host.name',
'cloud.availability_zone',
'cloud.machine.type',
'cloud.project.id',
'cloud.provider',
'service.type',
];
return (
<MetricsAndGroupByToolbarItems
{...props}
metricTypes={metricTypes}
groupByFields={groupByFields}
metricTypes={containerMetricTypes}
groupByFields={containerGroupByFields}
/>
);
};

View file

@ -10,20 +10,27 @@ import { ToolbarProps } from '../../../public/pages/metrics/inventory_view/compo
import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_groupby_toolbar_items';
import { SnapshotMetricType } from '../types';
export const hostMetricTypes: SnapshotMetricType[] = [
'cpu',
'memory',
'load',
'rx',
'tx',
'logRate',
];
export const hostGroupByFields = [
'cloud.availability_zone',
'cloud.machine.type',
'cloud.project.id',
'cloud.provider',
'service.type',
];
export const HostToolbarItems = (props: ToolbarProps) => {
const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'load', 'rx', 'tx', 'logRate'];
const groupByFields = [
'cloud.availability_zone',
'cloud.machine.type',
'cloud.project.id',
'cloud.provider',
'service.type',
];
return (
<MetricsAndGroupByToolbarItems
{...props}
metricTypes={metricTypes}
groupByFields={groupByFields}
metricTypes={hostMetricTypes}
groupByFields={hostGroupByFields}
/>
);
};

View file

@ -10,14 +10,15 @@ import { ToolbarProps } from '../../../public/pages/metrics/inventory_view/compo
import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_groupby_toolbar_items';
import { SnapshotMetricType } from '../types';
export const podMetricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx'];
export const podGroupByFields = ['kubernetes.namespace', 'kubernetes.node.name', 'service.type'];
export const PodToolbarItems = (props: ToolbarProps) => {
const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx'];
const groupByFields = ['kubernetes.namespace', 'kubernetes.node.name', 'service.type'];
return (
<MetricsAndGroupByToolbarItems
{...props}
metricTypes={metricTypes}
groupByFields={groupByFields}
metricTypes={podMetricTypes}
groupByFields={podGroupByFields}
/>
);
};

View file

@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { AlertFlyout } from './alert_flyout';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
export const AlertDropdown = () => {
export const MetricsAlertDropdown = () => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [flyoutVisible, setFlyoutVisible] = useState(false);
const kibana = useKibana();

View file

@ -34,6 +34,8 @@ import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer
import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by';
import { useSourceViaHttp } from '../../../containers/source/use_source_via_http';
import { convertKueryToElasticSearchQuery } from '../../../utils/kuery';
import { ExpressionRow } from './expression_row';
import { AlertContextMeta, TimeUnit, MetricExpression } from '../types';
import { ExpressionChart } from './expression_chart';
@ -45,6 +47,7 @@ interface Props {
groupBy?: string;
filterQuery?: string;
sourceId?: string;
filterQueryText?: string;
alertOnNoData?: boolean;
};
alertsContext: AlertsContextValue<AlertContextMeta>;
@ -111,11 +114,15 @@ export const Expressions: React.FC<Props> = props => {
[setAlertParams, alertParams.criteria]
);
const onFilterQuerySubmit = useCallback(
const onFilterChange = useCallback(
(filter: any) => {
setAlertParams('filterQuery', filter);
setAlertParams('filterQueryText', filter);
setAlertParams(
'filterQuery',
convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || ''
);
},
[setAlertParams]
[setAlertParams, derivedIndexPattern]
);
const onGroupByChange = useCallback(
@ -180,10 +187,19 @@ export const Expressions: React.FC<Props> = props => {
if (md.currentOptions) {
if (md.currentOptions.filterQuery) {
setAlertParams('filterQuery', md.currentOptions.filterQuery);
setAlertParams('filterQueryText', md.currentOptions.filterQuery);
setAlertParams(
'filterQuery',
convertKueryToElasticSearchQuery(md.currentOptions.filterQuery, derivedIndexPattern) ||
''
);
} else if (md.currentOptions.groupBy && md.series) {
const filter = `${md.currentOptions.groupBy}: "${md.series.id}"`;
setAlertParams('filterQuery', filter);
setAlertParams('filterQueryText', filter);
setAlertParams(
'filterQuery',
convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || ''
);
}
setAlertParams('groupBy', md.currentOptions.groupBy);
@ -200,8 +216,8 @@ export const Expressions: React.FC<Props> = props => {
}, [alertsContext.metadata, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps
const handleFieldSearchChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => onFilterQuerySubmit(e.target.value),
[onFilterQuerySubmit]
(e: ChangeEvent<HTMLInputElement>) => onFilterChange(e.target.value),
[onFilterChange]
);
return (
@ -304,13 +320,14 @@ export const Expressions: React.FC<Props> = props => {
{(alertsContext.metadata && (
<MetricsExplorerKueryBar
derivedIndexPattern={derivedIndexPattern}
onSubmit={onFilterQuerySubmit}
value={alertParams.filterQuery}
onChange={onFilterChange}
onSubmit={onFilterChange}
value={alertParams.filterQueryText}
/>
)) || (
<EuiFieldSearch
onChange={handleFieldSearchChange}
value={alertParams.filterQuery}
value={alertParams.filterQueryText}
fullWidth
/>
)}

View file

@ -0,0 +1,62 @@
/*
* 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 React, { useState, useCallback, useMemo } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { AlertFlyout } from './alert_flyout';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
export const InventoryAlertDropdown = () => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [flyoutVisible, setFlyoutVisible] = useState(false);
const kibana = useKibana();
const closePopover = useCallback(() => {
setPopoverOpen(false);
}, [setPopoverOpen]);
const openPopover = useCallback(() => {
setPopoverOpen(true);
}, [setPopoverOpen]);
const menuItems = useMemo(() => {
return [
<EuiContextMenuItem icon="bell" key="createLink" onClick={() => setFlyoutVisible(true)}>
<FormattedMessage
id="xpack.infra.alerting.createAlertButton"
defaultMessage="Create alert"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem
icon="tableOfContents"
key="manageLink"
href={kibana.services?.application?.getUrlForApp(
'kibana#/management/kibana/triggersActions/alerts'
)}
>
<FormattedMessage id="xpack.infra.alerting.manageAlerts" defaultMessage="Manage alerts" />
</EuiContextMenuItem>,
];
}, [kibana.services]);
return (
<>
<EuiPopover
button={
<EuiButtonEmpty iconSide={'right'} iconType={'arrowDown'} onClick={openPopover}>
<FormattedMessage id="xpack.infra.alerting.alertsButton" defaultMessage="Alerts" />
</EuiButtonEmpty>
}
isOpen={popoverOpen}
closePopover={closePopover}
>
<EuiContextMenuPanel items={menuItems} />
</EuiPopover>
<AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} />
</>
);
};

View file

@ -0,0 +1,52 @@
/*
* 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 React, { useContext } from 'react';
import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public';
import { TriggerActionsContext } from '../../../utils/triggers_actions_context';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/inventory_metric_threshold/types';
import { InfraWaffleMapOptions } from '../../../lib/lib';
import { InventoryItemType } from '../../../../common/inventory_models/types';
interface Props {
visible?: boolean;
options?: Partial<InfraWaffleMapOptions>;
nodeType?: InventoryItemType;
filter?: string;
setVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
export const AlertFlyout = (props: Props) => {
const { triggersActionsUI } = useContext(TriggerActionsContext);
const { services } = useKibana();
return (
<>
{triggersActionsUI && (
<AlertsContextProvider
value={{
metadata: { options: props.options, nodeType: props.nodeType, filter: props.filter },
toastNotifications: services.notifications?.toasts,
http: services.http,
docLinks: services.docLinks,
actionTypeRegistry: triggersActionsUI.actionTypeRegistry,
alertTypeRegistry: triggersActionsUI.alertTypeRegistry,
}}
>
<AlertAdd
addFlyoutVisible={props.visible!}
setAddFlyoutVisibility={props.setVisible}
alertTypeId={METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID}
canChangeTrigger={false}
consumer={'metrics'}
/>
</AlertsContextProvider>
)}
</>
);
};

View file

@ -0,0 +1,498 @@
/*
* 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 React, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiSpacer,
EuiText,
EuiFormRow,
EuiButtonEmpty,
EuiFieldSearch,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import {
Comparator,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../server/lib/alerting/metric_threshold/types';
import { euiStyled } from '../../../../../observability/public';
import {
ThresholdExpression,
ForLastExpression,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../triggers_actions_ui/public/common';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { IErrorObject } from '../../../../../triggers_actions_ui/public/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar';
import { useSourceViaHttp } from '../../../containers/source/use_source_via_http';
import { toMetricOpt } from '../../../pages/metrics/inventory_view/components/toolbars/toolbar_wrapper';
import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items';
import { ec2MetricTypes } from '../../../../common/inventory_models/aws_ec2/toolbar_items';
import { s3MetricTypes } from '../../../../common/inventory_models/aws_s3/toolbar_items';
import { rdsMetricTypes } from '../../../../common/inventory_models/aws_rds/toolbar_items';
import { hostMetricTypes } from '../../../../common/inventory_models/host/toolbar_items';
import { containerMetricTypes } from '../../../../common/inventory_models/container/toolbar_items';
import { podMetricTypes } from '../../../../common/inventory_models/pod/toolbar_items';
import { findInventoryModel } from '../../../../common/inventory_models';
import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types';
import { MetricExpression } from './metric';
import { NodeTypeExpression } from './node_type';
import { InfraWaffleMapOptions } from '../../../lib/lib';
import { convertKueryToElasticSearchQuery } from '../../../utils/kuery';
interface AlertContextMeta {
options?: Partial<InfraWaffleMapOptions>;
nodeType?: InventoryItemType;
filter?: string;
}
interface Props {
errors: IErrorObject[];
alertParams: {
criteria: InventoryMetricConditions[];
nodeType: InventoryItemType;
groupBy?: string;
filterQuery?: string;
filterQueryText?: string;
sourceId?: 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,
threshold: [],
timeSize: 1,
timeUnit: 'm',
} as InventoryMetricConditions;
export const Expressions: React.FC<Props> = props => {
const { setAlertParams, alertParams, errors, alertsContext } = props;
const { source, createDerivedIndexPattern } = useSourceViaHttp({
sourceId: 'default',
type: 'metrics',
fetch: alertsContext.http.fetch,
toastWarning: alertsContext.toastNotifications.addWarning,
});
const [timeSize, setTimeSize] = useState<number | undefined>(1);
const [timeUnit, setTimeUnit] = useState<TimeUnit>('m');
const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [
createDerivedIndexPattern,
]);
const updateParams = useCallback(
(id, e: InventoryMetricConditions) => {
const exp = alertParams.criteria ? alertParams.criteria.slice() : [];
exp[id] = { ...exp[id], ...e };
setAlertParams('criteria', exp);
},
[setAlertParams, alertParams.criteria]
);
const addExpression = useCallback(() => {
const exp = alertParams.criteria.slice();
exp.push(defaultExpression);
setAlertParams('criteria', exp);
}, [setAlertParams, alertParams.criteria]);
const removeExpression = useCallback(
(id: number) => {
const exp = alertParams.criteria.slice();
if (exp.length > 1) {
exp.splice(id, 1);
setAlertParams('criteria', exp);
}
},
[setAlertParams, alertParams.criteria]
);
const onFilterChange = useCallback(
(filter: any) => {
setAlertParams('filterQueryText', filter || '');
setAlertParams(
'filterQuery',
convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || ''
);
},
[derivedIndexPattern, setAlertParams]
);
const emptyError = useMemo(() => {
return {
aggField: [],
timeSizeUnit: [],
timeWindowSize: [],
};
}, []);
const updateTimeSize = useCallback(
(ts: number | undefined) => {
const criteria = alertParams.criteria.map(c => ({
...c,
timeSize: ts,
}));
setTimeSize(ts || undefined);
setAlertParams('criteria', criteria);
},
[alertParams.criteria, setAlertParams]
);
const updateTimeUnit = useCallback(
(tu: string) => {
const criteria = alertParams.criteria.map(c => ({
...c,
timeUnit: tu,
}));
setTimeUnit(tu as TimeUnit);
setAlertParams('criteria', criteria);
},
[alertParams.criteria, setAlertParams]
);
const updateNodeType = useCallback(
(nt: any) => {
setAlertParams('nodeType', nt);
},
[setAlertParams]
);
const handleFieldSearchChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => onFilterChange(e.target.value),
[onFilterChange]
);
useEffect(() => {
const md = alertsContext.metadata;
if (!alertParams.nodeType) {
if (md && md.nodeType) {
setAlertParams('nodeType', md.nodeType);
} else {
setAlertParams('nodeType', 'host');
}
}
if (!alertParams.criteria) {
if (md && md.options) {
setAlertParams('criteria', [
{
...defaultExpression,
metric: md.options.metric!.type,
} as InventoryMetricConditions,
]);
} else {
setAlertParams('criteria', [defaultExpression]);
}
}
if (!alertParams.filterQuery) {
if (md && md.filter) {
setAlertParams('filterQueryText', md.filter);
setAlertParams(
'filterQuery',
convertKueryToElasticSearchQuery(md.filter, derivedIndexPattern) || ''
);
}
}
if (!alertParams.sourceId) {
setAlertParams('sourceId', source?.id);
}
}, [alertsContext.metadata, derivedIndexPattern, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<EuiSpacer size={'m'} />
<EuiText size="xs">
<h4>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.conditions"
defaultMessage="Conditions"
/>
</h4>
</EuiText>
<StyledExpression>
<NodeTypeExpression
options={nodeTypes}
value={alertParams.nodeType || 'host'}
onChange={updateNodeType}
/>
</StyledExpression>
<EuiSpacer size={'xs'} />
{alertParams.criteria &&
alertParams.criteria.map((e, idx) => {
return (
<ExpressionRow
nodeType={alertParams.nodeType}
canDelete={alertParams.criteria.length > 1}
remove={removeExpression}
addExpression={addExpression}
key={idx} // idx's don't usually make good key's but here the index has semantic meaning
expressionId={idx}
setAlertParams={updateParams}
errors={errors[idx] || emptyError}
expression={e || {}}
/>
);
})}
<ForLastExpression
timeWindowSize={timeSize}
timeWindowUnit={timeUnit}
errors={emptyError}
onChangeWindowSize={updateTimeSize}
onChangeWindowUnit={updateTimeUnit}
/>
<div>
<EuiButtonEmpty
color={'primary'}
iconSide={'left'}
flush={'left'}
iconType={'plusInCircleFilled'}
onClick={addExpression}
>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.addCondition"
defaultMessage="Add condition"
/>
</EuiButtonEmpty>
</div>
<EuiSpacer size={'m'} />
<EuiFormRow
label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', {
defaultMessage: 'Filter (optional)',
})}
helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', {
defaultMessage: 'Use a KQL expression to limit the scope of your alert trigger.',
})}
fullWidth
compressed
>
{(alertsContext.metadata && (
<MetricsExplorerKueryBar
derivedIndexPattern={derivedIndexPattern}
onSubmit={onFilterChange}
onChange={onFilterChange}
value={alertParams.filterQueryText}
/>
)) || (
<EuiFieldSearch
onChange={handleFieldSearchChange}
value={alertParams.filterQueryText}
fullWidth
/>
)}
</EuiFormRow>
<EuiSpacer size={'m'} />
</>
);
};
interface ExpressionRowProps {
nodeType: InventoryItemType;
expressionId: number;
expression: Omit<InventoryMetricConditions, 'metric'> & {
metric?: SnapshotMetricType;
};
errors: IErrorObject;
canDelete: boolean;
addExpression(): void;
remove(id: number): void;
setAlertParams(id: number, params: Partial<InventoryMetricConditions>): void;
}
const StyledExpressionRow = euiStyled(EuiFlexGroup)`
display: flex;
flex-wrap: wrap;
margin: 0 -4px;
`;
const StyledExpression = euiStyled.div`
padding: 0 4px;
`;
export const ExpressionRow: React.FC<ExpressionRowProps> = props => {
const { setAlertParams, expression, errors, expressionId, remove, canDelete } = props;
const { metric, comparator = Comparator.GT, threshold = [] } = expression;
const updateMetric = useCallback(
(m?: SnapshotMetricType) => {
setAlertParams(expressionId, { ...expression, metric: m });
},
[expressionId, expression, setAlertParams]
);
const updateComparator = useCallback(
(c?: string) => {
setAlertParams(expressionId, { ...expression, comparator: c as Comparator | undefined });
},
[expressionId, expression, setAlertParams]
);
const updateThreshold = useCallback(
t => {
if (t.join() !== expression.threshold.join()) {
setAlertParams(expressionId, { ...expression, threshold: t });
}
},
[expressionId, expression, setAlertParams]
);
const ofFields = useMemo(() => {
let myMetrics = hostMetricTypes;
switch (props.nodeType) {
case 'awsEC2':
myMetrics = ec2MetricTypes;
break;
case 'awsRDS':
myMetrics = rdsMetricTypes;
break;
case 'awsS3':
myMetrics = s3MetricTypes;
break;
case 'awsSQS':
myMetrics = sqsMetricTypes;
break;
case 'host':
myMetrics = hostMetricTypes;
break;
case 'pod':
myMetrics = podMetricTypes;
break;
case 'container':
myMetrics = containerMetricTypes;
break;
}
return myMetrics.map(toMetricOpt);
}, [props.nodeType]);
return (
<>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow>
<StyledExpressionRow>
<StyledExpression>
<MetricExpression
metric={{
value: metric!,
text: ofFields.find(v => v?.value === metric)?.text || '',
}}
metrics={
ofFields.filter(m => m !== undefined && m.value !== undefined) as Array<{
value: SnapshotMetricType;
text: string;
}>
}
onChange={updateMetric}
errors={errors}
/>
</StyledExpression>
<StyledExpression>
<ThresholdExpression
thresholdComparator={comparator || Comparator.GT}
threshold={threshold}
onChangeSelectedThresholdComparator={updateComparator}
onChangeSelectedThreshold={updateThreshold}
errors={errors}
/>
</StyledExpression>
{metric && (
<StyledExpression>
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
<div>{metricUnit[metric]?.label || ''}</div>
</div>
</StyledExpression>
)}
</StyledExpressionRow>
</EuiFlexItem>
{canDelete && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.removeCondition', {
defaultMessage: 'Remove condition',
})}
color={'danger'}
iconType={'trash'}
onClick={() => remove(expressionId)}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size={'s'} />
</>
);
};
const getDisplayNameForType = (type: InventoryItemType) => {
const inventoryModel = findInventoryModel(type);
return inventoryModel.displayName;
};
export const nodeTypes: { [key: string]: any } = {
host: {
text: getDisplayNameForType('host'),
value: 'host',
},
pod: {
text: getDisplayNameForType('pod'),
value: 'pod',
},
container: {
text: getDisplayNameForType('container'),
value: 'container',
},
awsEC2: {
text: getDisplayNameForType('awsEC2'),
value: 'awsEC2',
},
awsS3: {
text: getDisplayNameForType('awsS3'),
value: 'awsS3',
},
awsRDS: {
text: getDisplayNameForType('awsRDS'),
value: 'awsRDS',
},
awsSQS: {
text: getDisplayNameForType('awsSQS'),
value: 'awsSQS',
},
};
const metricUnit: Record<string, { label: string }> = {
count: { label: '' },
cpu: { label: '%' },
memory: { label: '%' },
rx: { label: 'bits/s' },
tx: { label: 'bits/s' },
logRate: { label: '/s' },
diskIOReadBytes: { label: 'bytes/s' },
diskIOWriteBytes: { label: 'bytes/s' },
s3BucketSize: { label: 'bytes' },
s3TotalRequests: { label: '' },
s3NumberOfObjects: { label: '' },
s3UploadBytes: { label: 'bytes' },
s3DownloadBytes: { label: 'bytes' },
sqsOldestMessage: { label: 'seconds' },
};

View file

@ -0,0 +1,150 @@
/*
* 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 React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiExpression,
EuiPopover,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiComboBox,
} from '@elastic/eui';
import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { IErrorObject } from '../../../../../triggers_actions_ui/public/types';
import { SnapshotMetricType } from '../../../../common/inventory_models/types';
interface Props {
metric?: { value: SnapshotMetricType; text: string };
metrics: Array<{ value: string; text: string }>;
errors: IErrorObject;
onChange: (metric: SnapshotMetricType) => void;
popupPosition?:
| 'upCenter'
| 'upLeft'
| 'upRight'
| 'downCenter'
| 'downLeft'
| 'downRight'
| 'leftCenter'
| 'leftUp'
| 'leftDown'
| 'rightCenter'
| 'rightUp'
| 'rightDown';
}
export const MetricExpression = ({ metric, metrics, errors, onChange, popupPosition }: Props) => {
const [aggFieldPopoverOpen, setAggFieldPopoverOpen] = useState(false);
const firstFieldOption = {
text: i18n.translate('xpack.infra.metrics.alertFlyout.expression.metric.selectFieldLabel', {
defaultMessage: 'Select a metric',
}),
value: '',
};
const availablefieldsOptions = metrics.map(m => {
return { label: m.text, value: m.value };
}, []);
return (
<EuiPopover
id="aggFieldPopover"
button={
<EuiExpression
description={i18n.translate(
'xpack.infra.metrics.alertFlyout.expression.metric.whenLabel',
{
defaultMessage: 'When',
}
)}
value={metric?.text || firstFieldOption.text}
isActive={aggFieldPopoverOpen || !metric}
onClick={() => {
setAggFieldPopoverOpen(true);
}}
color={metric ? 'secondary' : 'danger'}
/>
}
isOpen={aggFieldPopoverOpen}
closePopover={() => {
setAggFieldPopoverOpen(false);
}}
withTitle
anchorPosition={popupPosition ?? 'downRight'}
zIndex={8000}
>
<div>
<ClosablePopoverTitle onClose={() => setAggFieldPopoverOpen(false)}>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.expression.metric.popoverTitle"
defaultMessage="Metric"
/>
</ClosablePopoverTitle>
<EuiFlexGroup>
<EuiFlexItem grow={false} className="actOf__aggFieldContainer">
<EuiFormRow
fullWidth
isInvalid={errors.metric.length > 0 && metric !== undefined}
error={errors.metric}
>
<EuiComboBox
fullWidth
singleSelection={{ asPlainText: true }}
data-test-subj="availablefieldsOptionsComboBox"
isInvalid={errors.metric.length > 0 && metric !== undefined}
placeholder={firstFieldOption.text}
options={availablefieldsOptions}
noSuggestions={!availablefieldsOptions.length}
selectedOptions={
metric ? availablefieldsOptions.filter(a => a.value === metric.value) : []
}
renderOption={(o: any) => o.label}
onChange={selectedOptions => {
if (selectedOptions.length > 0) {
onChange(selectedOptions[0].value as SnapshotMetricType);
setAggFieldPopoverOpen(false);
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</EuiPopover>
);
};
interface ClosablePopoverTitleProps {
children: JSX.Element;
onClose: () => void;
}
export const ClosablePopoverTitle = ({ children, onClose }: ClosablePopoverTitleProps) => {
return (
<EuiPopoverTitle>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>{children}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
color="danger"
aria-label={i18n.translate(
'xpack.infra.metrics.expressionItems.components.closablePopoverTitle.closeLabel',
{
defaultMessage: 'Close',
}
)}
onClick={() => onClose()}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
);
};

View file

@ -0,0 +1,34 @@
/*
* 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';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types';
import { Expressions } from './expression';
import { validateMetricThreshold } from './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 {
return {
id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID,
name: i18n.translate('xpack.infra.metrics.inventory.alertFlyout.alertName', {
defaultMessage: 'Inventory',
}),
iconClass: 'bell',
alertParamsExpression: Expressions,
validate: validateMetricThreshold,
defaultActionMessage: i18n.translate(
'xpack.infra.metrics.alerting.inventory.threshold.defaultActionMessage',
{
defaultMessage: `\\{\\{alertName\\}\\} - \\{\\{context.group\\}\\}
\\{\\{context.metricOf.condition0\\}\\} has crossed a threshold of \\{\\{context.thresholdOf.condition0\\}\\}
Current value is \\{\\{context.valueOf.condition0\\}\\}
`,
}
),
};
}

View file

@ -0,0 +1,115 @@
/*
* 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 React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiExpression, EuiPopover, EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui';
import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui';
import { InventoryItemType } from '../../../../common/inventory_models/types';
interface WhenExpressionProps {
value: InventoryItemType;
options: { [key: string]: { text: string; value: InventoryItemType } };
onChange: (value: InventoryItemType) => void;
popupPosition?:
| 'upCenter'
| 'upLeft'
| 'upRight'
| 'downCenter'
| 'downLeft'
| 'downRight'
| 'leftCenter'
| 'leftUp'
| 'leftDown'
| 'rightCenter'
| 'rightUp'
| 'rightDown';
}
export const NodeTypeExpression = ({
value,
options,
onChange,
popupPosition,
}: WhenExpressionProps) => {
const [aggTypePopoverOpen, setAggTypePopoverOpen] = useState(false);
return (
<EuiPopover
button={
<EuiExpression
data-test-subj="nodeTypeExpression"
description={i18n.translate(
'xpack.infra.metrics.alertFlyout.expression.for.descriptionLabel',
{
defaultMessage: 'For',
}
)}
value={options[value].text}
isActive={aggTypePopoverOpen}
onClick={() => {
setAggTypePopoverOpen(true);
}}
/>
}
isOpen={aggTypePopoverOpen}
closePopover={() => {
setAggTypePopoverOpen(false);
}}
ownFocus
withTitle
anchorPosition={popupPosition ?? 'downLeft'}
>
<div>
<ClosablePopoverTitle onClose={() => setAggTypePopoverOpen(false)}>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.expression.for.popoverTitle"
defaultMessage="Inventory Type"
/>
</ClosablePopoverTitle>
<EuiSelect
data-test-subj="forExpressionSelect"
value={value}
fullWidth
onChange={e => {
onChange(e.target.value as InventoryItemType);
setAggTypePopoverOpen(false);
}}
options={Object.values(options).map(o => o)}
/>
</div>
</EuiPopover>
);
};
interface ClosablePopoverTitleProps {
children: JSX.Element;
onClose: () => void;
}
export const ClosablePopoverTitle = ({ children, onClose }: ClosablePopoverTitleProps) => {
return (
<EuiPopoverTitle>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>{children}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
color="danger"
aria-label={i18n.translate(
'xpack.infra.metrics.expressionItems.components.closablePopoverTitle.closeLabel',
{
defaultMessage: 'Close',
}
)}
onClick={() => onClose()}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
);
};

View file

@ -0,0 +1,80 @@
/*
* 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';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { MetricExpressionParams } from '../../../../server/lib/alerting/metric_threshold/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ValidationResult } from '../../../../../triggers_actions_ui/public/types';
export function validateMetricThreshold({
criteria,
}: {
criteria: MetricExpressionParams[];
}): ValidationResult {
const validationResult = { errors: {} };
const errors: {
[id: string]: {
timeSizeUnit: string[];
timeWindowSize: string[];
threshold0: string[];
threshold1: string[];
metric: string[];
};
} = {};
validationResult.errors = errors;
if (!criteria || !criteria.length) {
return validationResult;
}
criteria.forEach((c, idx) => {
// Create an id for each criteria, so we can map errors to specific criteria.
const id = idx.toString();
errors[id] = errors[id] || {
timeSizeUnit: [],
timeWindowSize: [],
threshold0: [],
threshold1: [],
metric: [],
};
if (!c.threshold || !c.threshold.length) {
errors[id].threshold0.push(
i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', {
defaultMessage: 'Threshold is required.',
})
);
}
if (c.comparator === 'between' && (!c.threshold || c.threshold.length < 2)) {
errors[id].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', {
defaultMessage: 'Time size is Required.',
})
);
}
if (!c.metric && c.aggType !== 'count') {
errors[id].metric.push(
i18n.translate('xpack.infra.metrics.alertFlyout.error.metricRequired', {
defaultMessage: 'Metric is required.',
})
);
}
});
return validationResult;
}

View file

@ -22,7 +22,7 @@ import {
} from './log_entry_column';
import { ASSUMED_SCROLLBAR_WIDTH } from './vertical_scroll_panel';
import { LogPositionState } from '../../../containers/logs/log_position';
import { localizedDate } from '../../../utils/formatters/datetime';
import { localizedDate } from '../../../../common/formatters/datetime';
export const LogColumnHeaders: React.FunctionComponent<{
columnConfigurations: LogColumnConfiguration[];

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiTitle } from '@elastic/eui';
import { localizedDate } from '../../../utils/formatters/datetime';
import { localizedDate } from '../../../../common/formatters/datetime';
interface LogDateRowProps {
timestamp: number;

View file

@ -3,7 +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 { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useCallback } from 'react';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
@ -69,12 +69,15 @@ export const useSourceViaHttp = ({
})();
}, [makeRequest]);
const createDerivedIndexPattern = (indexType: 'logs' | 'metrics' | 'both' = type) => {
return {
fields: response?.source ? response.status.indexFields : [],
title: pickIndexPattern(response?.source, indexType),
};
};
const createDerivedIndexPattern = useCallback(
(indexType: 'logs' | 'metrics' | 'both' = type) => {
return {
fields: response?.source ? response.status.indexFields : [],
title: pickIndexPattern(response?.source, indexType),
};
},
[response, type]
);
const source = useMemo(() => {
return response ? { ...response.source, status: response.status } : null;

View file

@ -16,7 +16,7 @@ export const plugin: PluginInitializer<
return new Plugin(context);
};
export { FORMATTERS } from './utils/formatters';
export { FORMATTERS } from '../common/formatters';
export { InfraFormatterType } from './lib/lib';
export type InfraAppId = 'logs' | 'metrics';

View file

@ -186,12 +186,6 @@ export enum InfraFormatterType {
percent = 'percent',
}
export enum InfraWaffleMapDataFormat {
bytesDecimal = 'bytesDecimal',
bitsDecimal = 'bitsDecimal',
abbreviatedNumber = 'abbreviatedNumber',
}
export interface InfraGroupByOptions {
text: string;
field: string;

View file

@ -28,7 +28,9 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
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 { AlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown';
import { InventoryAlertDropdown } from '../../components/alerting/inventory/alert_dropdown';
import { MetricsAlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown';
export const InfrastructurePage = ({ match }: RouteComponentProps) => {
const uiCapabilities = useKibana().services.application?.capabilities;
@ -96,7 +98,8 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => {
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Route path={'/explorer'} component={AlertDropdown} />
<Route path={'/explorer'} component={MetricsAlertDropdown} />
<Route path={'/inventory'} component={InventoryAlertDropdown} />
</EuiFlexItem>
</EuiFlexGroup>
</AppNavigation>

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 '../../../../../alerting/metric_threshold/components/alert_flyout';
import { AlertFlyout } from '../../../../../components/alerting/inventory/alert_flyout';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib';
import { getNodeDetailUrl, getNodeLogsUrl } from '../../../../link_to';
import { createUptimeLink } from '../../lib/create_uptime_link';
@ -24,6 +24,8 @@ import {
SectionSubtitle,
SectionLinks,
SectionLink,
withTheme,
EuiTheme,
} from '../../../../../../../observability/public';
import { useLinkProps } from '../../../../../hooks/use_link_props';
@ -37,157 +39,178 @@ interface Props {
popoverPosition: EuiPopoverProps['anchorPosition'];
}
export const NodeContextMenu: React.FC<Props> = ({
options,
currentTime,
children,
node,
isPopoverOpen,
closePopover,
nodeType,
popoverPosition,
}) => {
const [flyoutVisible, setFlyoutVisible] = useState(false);
const inventoryModel = findInventoryModel(nodeType);
const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000;
const uiCapabilities = useKibana().services.application?.capabilities;
// Due to the changing nature of the fields between APM and this UI,
// We need to have some exceptions until 7.0 & ECS is finalized. Reference
// #26620 for the details for these fields.
// TODO: This is tech debt, remove it after 7.0 & ECS migration.
const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id;
export const NodeContextMenu: React.FC<Props & { theme?: EuiTheme }> = withTheme(
({
options,
currentTime,
children,
node,
isPopoverOpen,
closePopover,
nodeType,
popoverPosition,
theme,
}) => {
const [flyoutVisible, setFlyoutVisible] = useState(false);
const inventoryModel = findInventoryModel(nodeType);
const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000;
const uiCapabilities = useKibana().services.application?.capabilities;
// Due to the changing nature of the fields between APM and this UI,
// We need to have some exceptions until 7.0 & ECS is finalized. Reference
// #26620 for the details for these fields.
// TODO: This is tech debt, remove it after 7.0 & ECS migration.
const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id;
const showDetail = inventoryModel.crosslinkSupport.details;
const showLogsLink =
inventoryModel.crosslinkSupport.logs && node.id && uiCapabilities?.logs?.show;
const showAPMTraceLink =
inventoryModel.crosslinkSupport.apm && uiCapabilities?.apm && uiCapabilities?.apm.show;
const showUptimeLink =
inventoryModel.crosslinkSupport.uptime && (['pod', 'container'].includes(nodeType) || node.ip);
const showDetail = inventoryModel.crosslinkSupport.details;
const showLogsLink =
inventoryModel.crosslinkSupport.logs && node.id && uiCapabilities?.logs?.show;
const showAPMTraceLink =
inventoryModel.crosslinkSupport.apm && uiCapabilities?.apm && uiCapabilities?.apm.show;
const showUptimeLink =
inventoryModel.crosslinkSupport.uptime &&
(['pod', 'container'].includes(nodeType) || node.ip);
const inventoryId = useMemo(() => {
if (nodeType === 'host') {
if (node.ip) {
return { label: <EuiCode>host.ip</EuiCode>, value: node.ip };
const inventoryId = useMemo(() => {
if (nodeType === 'host') {
if (node.ip) {
return { label: <EuiCode>host.ip</EuiCode>, value: node.ip };
}
} else {
if (options.fields) {
const { id } = findInventoryFields(nodeType, options.fields);
return {
label: <EuiCode>{id}</EuiCode>,
value: node.id,
};
}
}
} else {
if (options.fields) {
const { id } = findInventoryFields(nodeType, options.fields);
return {
label: <EuiCode>{id}</EuiCode>,
value: node.id,
};
}
}
return { label: '', value: '' };
}, [nodeType, node.ip, node.id, options.fields]);
return { label: '', value: '' };
}, [nodeType, node.ip, node.id, options.fields]);
const nodeLogsMenuItemLinkProps = useLinkProps({
app: 'logs',
...getNodeLogsUrl({
nodeType,
nodeId: node.id,
time: currentTime,
}),
});
const nodeDetailMenuItemLinkProps = useLinkProps({
...getNodeDetailUrl({
nodeType,
nodeId: node.id,
from: nodeDetailFrom,
to: currentTime,
}),
});
const apmTracesMenuItemLinkProps = useLinkProps({
app: 'apm',
hash: 'traces',
search: {
kuery: `${apmField}:"${node.id}"`,
},
});
const uptimeMenuItemLinkProps = useLinkProps(createUptimeLink(options, nodeType, node));
const nodeLogsMenuItemLinkProps = useLinkProps({
app: 'logs',
...getNodeLogsUrl({
nodeType,
nodeId: node.id,
time: currentTime,
}),
});
const nodeDetailMenuItemLinkProps = useLinkProps({
...getNodeDetailUrl({
nodeType,
nodeId: node.id,
from: nodeDetailFrom,
to: currentTime,
}),
});
const apmTracesMenuItemLinkProps = useLinkProps({
app: 'apm',
hash: 'traces',
search: {
kuery: `${apmField}:"${node.id}"`,
},
});
const uptimeMenuItemLinkProps = useLinkProps(createUptimeLink(options, nodeType, node));
const nodeLogsMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.viewLogsName', {
defaultMessage: '{inventoryName} logs',
values: { inventoryName: inventoryModel.singularDisplayName },
}),
...nodeLogsMenuItemLinkProps,
'data-test-subj': 'viewLogsContextMenuItem',
isDisabled: !showLogsLink,
};
const nodeLogsMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.viewLogsName', {
defaultMessage: '{inventoryName} logs',
values: { inventoryName: inventoryModel.singularDisplayName },
}),
...nodeLogsMenuItemLinkProps,
'data-test-subj': 'viewLogsContextMenuItem',
isDisabled: !showLogsLink,
};
const nodeDetailMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.viewMetricsName', {
defaultMessage: '{inventoryName} metrics',
values: { inventoryName: inventoryModel.singularDisplayName },
}),
...nodeDetailMenuItemLinkProps,
isDisabled: !showDetail,
};
const nodeDetailMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.viewMetricsName', {
defaultMessage: '{inventoryName} metrics',
values: { inventoryName: inventoryModel.singularDisplayName },
}),
...nodeDetailMenuItemLinkProps,
isDisabled: !showDetail,
};
const apmTracesMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.viewAPMTraces', {
defaultMessage: '{inventoryName} APM traces',
values: { inventoryName: inventoryModel.singularDisplayName },
}),
...apmTracesMenuItemLinkProps,
'data-test-subj': 'viewApmTracesContextMenuItem',
isDisabled: !showAPMTraceLink,
};
const apmTracesMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.viewAPMTraces', {
defaultMessage: '{inventoryName} APM traces',
values: { inventoryName: inventoryModel.singularDisplayName },
}),
...apmTracesMenuItemLinkProps,
'data-test-subj': 'viewApmTracesContextMenuItem',
isDisabled: !showAPMTraceLink,
};
const uptimeMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.viewUptimeLink', {
defaultMessage: '{inventoryName} in Uptime',
values: { inventoryName: inventoryModel.singularDisplayName },
}),
...uptimeMenuItemLinkProps,
isDisabled: !showUptimeLink,
};
const uptimeMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.viewUptimeLink', {
defaultMessage: '{inventoryName} in Uptime',
values: { inventoryName: inventoryModel.singularDisplayName },
}),
...uptimeMenuItemLinkProps,
isDisabled: !showUptimeLink,
};
return (
<>
<ActionMenu
closePopover={closePopover}
id={`${node.pathId}-popover`}
isOpen={isPopoverOpen}
button={children!}
anchorPosition={popoverPosition}
>
<div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu">
<Section>
<SectionTitle>
<FormattedMessage
id="xpack.infra.nodeContextMenu.title"
defaultMessage="{inventoryName} details"
values={{ inventoryName: inventoryModel.singularDisplayName }}
/>
</SectionTitle>
{inventoryId.label && (
<SectionSubtitle>
<div style={{ wordBreak: 'break-all' }}>
<FormattedMessage
id="xpack.infra.nodeContextMenu.description"
defaultMessage="View details for {label} {value}"
values={{ label: inventoryId.label, value: inventoryId.value }}
/>
</div>
</SectionSubtitle>
)}
<SectionLinks>
<SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} />
<SectionLink {...nodeDetailMenuItem} />
<SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} />
<SectionLink {...uptimeMenuItem} />
</SectionLinks>
</Section>
</div>
</ActionMenu>
<AlertFlyout
options={{ filterQuery: `${nodeType}: ${node.id}` }}
setVisible={setFlyoutVisible}
visible={flyoutVisible}
/>
</>
);
};
const createAlertMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.createAlertLink', {
defaultMessage: 'Create alert',
}),
style: { color: theme?.eui.euiLinkColor || '#006BB4', fontWeight: 500, padding: 0 },
onClick: () => {
setFlyoutVisible(true);
},
};
return (
<>
<ActionMenu
closePopover={closePopover}
id={`${node.pathId}-popover`}
isOpen={isPopoverOpen}
button={children!}
anchorPosition={popoverPosition}
>
<div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu">
<Section>
<SectionTitle>
<FormattedMessage
id="xpack.infra.nodeContextMenu.title"
defaultMessage="{inventoryName} details"
values={{ inventoryName: inventoryModel.singularDisplayName }}
/>
</SectionTitle>
{inventoryId.label && (
<SectionSubtitle>
<div style={{ wordBreak: 'break-all' }}>
<FormattedMessage
id="xpack.infra.nodeContextMenu.description"
defaultMessage="View details for {label} {value}"
values={{ label: inventoryId.label, value: inventoryId.value }}
/>
</div>
</SectionSubtitle>
)}
<SectionLinks>
<SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} />
<SectionLink {...nodeDetailMenuItem} />
<SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} />
<SectionLink {...uptimeMenuItem} />
<SectionLink {...createAlertMenuItem} />
</SectionLinks>
</Section>
</div>
</ActionMenu>
<AlertFlyout
filter={
options.fields
? `${findInventoryFields(nodeType, options.fields).id}: "${node.id}"`
: ''
}
options={options}
nodeType={nodeType}
setVisible={setFlyoutVisible}
visible={flyoutVisible}
/>
</>
);
}
);

View file

@ -5,13 +5,13 @@
*/
import { get } from 'lodash';
import { createFormatter } from '../../../../utils/formatters';
import { InfraFormatterType } from '../../../../lib/lib';
import {
SnapshotMetricInput,
SnapshotCustomMetricInputRT,
} from '../../../../../common/http_api/snapshot_api';
import { createFormatterForMetric } from '../../metrics_explorer/components/helpers/create_formatter_for_metric';
import { createFormatter } from '../../../../../common/formatters';
interface MetricFormatter {
formatter: InfraFormatterType;

View file

@ -17,7 +17,7 @@ import { get, last, max } from 'lodash';
import React, { ReactText } from 'react';
import { euiStyled } from '../../../../../../observability/public';
import { createFormatter } from '../../../../utils/formatters';
import { createFormatter } from '../../../../../common/formatters';
import { InventoryFormatterType } from '../../../../../common/inventory_models/types';
import { SeriesOverrides, VisSectionProps } from '../types';
import { getChartName } from './helpers';

View file

@ -7,7 +7,7 @@
import { ReactText } from 'react';
import Color from 'color';
import { get, first, last, min, max } from 'lodash';
import { createFormatter } from '../../../../utils/formatters';
import { createFormatter } from '../../../../../common/formatters';
import { InfraDataSeries } from '../../../../graphql/types';
import {
InventoryVisTypeRT,

View file

@ -5,7 +5,7 @@
*/
import { MetricsExplorerMetric } from '../../../../../../common/http_api/metrics_explorer';
import { createFormatter } from '../../../../../utils/formatters';
import { createFormatter } from '../../../../../../common/formatters';
import { InfraFormatterType } from '../../../../../lib/lib';
import { metricToFormat } from './metric_to_format';
export const createFormatterForMetric = (metric?: MetricsExplorerMetric) => {

View file

@ -14,6 +14,7 @@ import { esKuery, IIndexPattern } from '../../../../../../../../src/plugins/data
interface Props {
derivedIndexPattern: IIndexPattern;
onSubmit: (query: string) => void;
onChange?: (query: string) => void;
value?: string | null;
placeholder?: string;
}
@ -30,6 +31,7 @@ function validateQuery(query: string) {
export const MetricsExplorerKueryBar = ({
derivedIndexPattern,
onSubmit,
onChange,
value,
placeholder,
}: Props) => {
@ -46,6 +48,9 @@ export const MetricsExplorerKueryBar = ({
const handleChange = (query: string) => {
setValidation(validateQuery(query));
setDraftQuery(query);
if (onChange) {
onChange(query);
}
};
const filteredDerivedIndexPattern = {

View file

@ -22,6 +22,7 @@ import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public
import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public';
import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type';
import { getInventoryMetricAlertType } from './components/alerting/inventory/metric_inventory_threshold_alert_type';
import { createMetricThresholdAlertType } from './alerting/metric_threshold';
export type ClientSetup = void;
@ -53,6 +54,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(getLogsAlertType());
pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createMetricThresholdAlertType());

View file

@ -101,7 +101,9 @@ export const createSourcesResolvers = (
return requestedSourceConfiguration;
},
async allSources(root, args, { req }) {
const sourceConfigurations = await libs.sources.getAllSourceConfigurations(req);
const sourceConfigurations = await libs.sources.getAllSourceConfigurations(
req.core.savedObjects.client
);
return sourceConfigurations;
},
@ -131,7 +133,7 @@ export const createSourcesResolvers = (
Mutation: {
async createSource(root, args, { req }) {
const sourceConfiguration = await libs.sources.createSourceConfiguration(
req,
req.core.savedObjects.client,
args.id,
compactObject({
...args.sourceProperties,
@ -147,7 +149,7 @@ export const createSourcesResolvers = (
};
},
async deleteSource(root, args, { req }) {
await libs.sources.deleteSourceConfiguration(req, args.id);
await libs.sources.deleteSourceConfiguration(req.core.savedObjects.client, args.id);
return {
id: args.id,
@ -155,7 +157,7 @@ export const createSourcesResolvers = (
},
async updateSource(root, args, { req }) {
const updatedSourceConfiguration = await libs.sources.updateSourceConfiguration(
req,
req.core.savedObjects.client,
args.id,
compactObject({
...args.sourceProperties,

View file

@ -18,6 +18,7 @@ import {
InventoryMetricRT,
} from '../../../../common/inventory_models/types';
import { calculateMetricInterval } from '../../../utils/calculate_metric_interval';
import { CallWithRequestParams, InfraDatabaseSearchResponse } from '../framework';
export class KibanaMetricsAdapter implements InfraMetricsAdapter {
private framework: KibanaFramework;
@ -120,9 +121,14 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter {
indexPattern,
options.timerange.interval
);
const client = <Hit = {}, Aggregation = undefined>(
opts: CallWithRequestParams
): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> =>
this.framework.callWithRequest(requestContext, 'search', opts);
const calculatedInterval = await calculateMetricInterval(
this.framework,
requestContext,
client,
{
indexPattern: `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`,
timestampField: options.sourceConfiguration.fields.timestamp,

View file

@ -0,0 +1,214 @@
/*
* 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, 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 '../../../../../alerting/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';
import { InfraBackendLibs } from '../../infra_types';
import { METRIC_FORMATTERS } from '../../../../common/formatters/snapshot_metric_formats';
import { createFormatter } from '../../../../common/formatters';
interface InventoryMetricThresholdParams {
criteria: InventoryMetricConditions[];
groupBy: string | undefined;
filterQuery: string | undefined;
nodeType: InventoryItemType;
sourceId?: string;
}
export const createInventoryMetricThresholdExecutor = (
libs: InfraBackendLibs,
alertId: string
) => async ({ services, params }: AlertExecutorOptions) => {
const { criteria, filterQuery, sourceId, nodeType } = params as InventoryMetricThresholdParams;
const source = await libs.sources.getSourceConfiguration(
services.savedObjectsClient,
sourceId || 'default'
);
const results = await Promise.all(
criteria.map(c => evaluateCondtion(c, nodeType, source.configuration, services, filterQuery))
);
const invenotryItems = Object.keys(results[0]);
for (const item of invenotryItems) {
const alertInstance = services.alertInstanceFactory(`${alertId}-${item}`);
// AND logic; all criteria must be across the threshold
const shouldAlertFire = results.every(result => result[item].shouldFire);
// 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 = results.some(result => result[item].isNoData);
const isError = results.some(result => result[item].isError);
if (shouldAlertFire) {
alertInstance.scheduleActions(FIRED_ACTIONS.id, {
group: item,
item,
valueOf: mapToConditionsLookup(results, result =>
formatMetric(result[item].metric, result[item].currentValue)
),
thresholdOf: mapToConditionsLookup(criteria, c => c.threshold),
metricOf: mapToConditionsLookup(criteria, c => c.metric),
});
}
alertInstance.replaceState({
alertState: isError
? AlertStates.ERROR
: isNoData
? AlertStates.NO_DATA
: shouldAlertFire
? AlertStates.ALERT
: AlertStates.OK,
});
}
};
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
) =>
list
.map(mapFn)
.reduce(
(result: Record<string, any>, value, i) => ({ ...result, [`condition${i}`]: value }),
{}
);
export const FIRED_ACTIONS = {
id: 'metrics.invenotry_threshold.fired',
name: i18n.translate('xpack.infra.metrics.alerting.inventory.threshold.fired', {
defaultMessage: 'Fired',
}),
};
// 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);
// return formatter(val);
// }
const metricFormatter = get(METRIC_FORMATTERS, metric, METRIC_FORMATTERS.count);
if (value == null) {
return '';
}
const formatter = createFormatter(metricFormatter.formatter, metricFormatter.template);
return formatter(value);
};

View file

@ -0,0 +1,92 @@
/*
* 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';
import { schema } from '@kbn/config-schema';
import { curry } from 'lodash';
import uuid from 'uuid';
import {
createInventoryMetricThresholdExecutor,
FIRED_ACTIONS,
} from './inventory_metric_threshold_executor';
import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from './types';
import { InfraBackendLibs } from '../../infra_types';
const condition = schema.object({
threshold: schema.arrayOf(schema.number()),
comparator: schema.oneOf([
schema.literal('>'),
schema.literal('<'),
schema.literal('>='),
schema.literal('<='),
schema.literal('between'),
schema.literal('outside'),
]),
timeUnit: schema.string(),
timeSize: schema.number(),
metric: schema.string(),
});
export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs) => ({
id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID,
name: 'Inventory',
validate: {
params: schema.object(
{
criteria: schema.arrayOf(condition),
nodeType: schema.string(),
filterQuery: schema.maybe(schema.string()),
sourceId: schema.string(),
},
{ unknowns: 'allow' }
),
},
defaultActionGroupId: FIRED_ACTIONS.id,
actionGroups: [FIRED_ACTIONS],
executor: curry(createInventoryMetricThresholdExecutor)(libs, uuid.v4()),
actionVariables: {
context: [
{
name: 'group',
description: i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.groupActionVariableDescription',
{
defaultMessage: 'Name of the group reporting data',
}
),
},
{
name: 'valueOf',
description: i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.valueOfActionVariableDescription',
{
defaultMessage:
'Record of the current value of the watched metric; grouped by condition, i.e valueOf.condition0, valueOf.condition1, etc.',
}
),
},
{
name: 'thresholdOf',
description: i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.thresholdOfActionVariableDescription',
{
defaultMessage:
'Record of the alerting threshold; grouped by condition, i.e thresholdOf.condition0, thresholdOf.condition1, etc.',
}
),
},
{
name: 'metricOf',
description: i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.metricOfActionVariableDescription',
{
defaultMessage:
'Record of the watched metric; grouped by condition, i.e metricOf.condition0, metricOf.condition1, etc.',
}
),
},
],
},
});

View file

@ -0,0 +1,35 @@
/*
* 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 { SnapshotMetricType } from '../../../../common/inventory_models/types';
export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold';
export enum Comparator {
GT = '>',
LT = '<',
GT_OR_EQ = '>=',
LT_OR_EQ = '<=',
BETWEEN = 'between',
OUTSIDE_RANGE = 'outside',
}
export enum AlertStates {
OK,
ALERT,
NO_DATA,
ERROR,
}
export type TimeUnit = 's' | 'm' | 'h' | 'd';
export interface InventoryMetricConditions {
metric: SnapshotMetricType;
timeSize: number;
timeUnit: TimeUnit;
sourceId?: string;
threshold: number[];
comparator: Comparator;
}

View file

@ -3,7 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor';
import { Comparator, AlertStates } from './types';
import * as mocks from './test_mocks';
@ -13,81 +12,14 @@ import {
AlertServicesMock,
AlertInstanceMock,
} from '../../../../../alerting/server/mocks';
const executor = createMetricThresholdExecutor('test') as (opts: {
params: AlertExecutorOptions['params'];
services: { callCluster: AlertExecutorOptions['params']['callCluster'] };
}) => Promise<void>;
const services: AlertServicesMock = alertsMock.createAlertServices();
services.callCluster.mockImplementation(async (_: string, { body, index }: any) => {
if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse;
const metric = body.query.bool.filter[1]?.exists.field;
if (body.aggs.groupings) {
if (body.aggs.groupings.composite.after) {
return mocks.compositeEndResponse;
}
if (metric === 'test.metric.2') {
return mocks.alternateCompositeResponse;
}
return mocks.basicCompositeResponse;
}
if (metric === 'test.metric.2') {
return mocks.alternateMetricResponse;
} else if (metric === 'test.metric.3') {
return mocks.emptyMetricResponse;
}
return mocks.basicMetricResponse;
});
services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => {
if (sourceId === 'alternate')
return {
id: 'alternate',
attributes: { metricAlias: 'alternatebeat-*' },
type,
references: [],
};
return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] };
});
import { InfraSources } from '../../sources';
interface AlertTestInstance {
instance: AlertInstanceMock;
actionQueue: any[];
state: any;
}
const alertInstances = new Map<string, AlertTestInstance>();
services.alertInstanceFactory.mockImplementation((instanceID: string) => {
const alertInstance: AlertTestInstance = {
instance: alertsMock.createAlertInstanceFactory(),
actionQueue: [],
state: {},
};
alertInstances.set(instanceID, alertInstance);
alertInstance.instance.replaceState.mockImplementation((newState: any) => {
alertInstance.state = newState;
return alertInstance.instance;
});
alertInstance.instance.scheduleActions.mockImplementation((id: string, action: any) => {
alertInstance.actionQueue.push({ id, action });
return alertInstance.instance;
});
return alertInstance.instance;
});
function mostRecentAction(id: string) {
return alertInstances.get(id)!.actionQueue.pop();
}
function getState(id: string) {
return alertInstances.get(id)!.state;
}
const baseCriterion = {
aggType: 'avg',
metric: 'test.metric.1',
timeSize: 1,
timeUnit: 'm',
};
describe('The metric threshold alert type', () => {
describe('querying the entire infrastructure', () => {
const instanceID = 'test-*';
@ -167,14 +99,6 @@ describe('The metric threshold alert type', () => {
expect(action.reason).toContain('threshold of 0.75');
expect(action.reason).toContain('test.metric.1');
});
test('fetches the index pattern dynamically', async () => {
await execute(Comparator.LT, [17], 'alternate');
expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id);
expect(getState(instanceID).alertState).toBe(AlertStates.ALERT);
await execute(Comparator.LT, [1.5], 'alternate');
expect(mostRecentAction(instanceID)).toBe(undefined);
expect(getState(instanceID).alertState).toBe(AlertStates.OK);
});
});
describe('querying with a groupBy parameter', () => {
@ -338,3 +262,117 @@ describe('The metric threshold alert type', () => {
});
});
});
const createMockStaticConfiguration = (sources: any) => ({
enabled: true,
query: {
partitionSize: 1,
partitionFactor: 1,
},
sources,
});
const mockLibs: any = {
sources: new InfraSources({
config: createMockStaticConfiguration({}),
}),
configuration: createMockStaticConfiguration({}),
};
const executor = createMetricThresholdExecutor(mockLibs, 'test') as (opts: {
params: AlertExecutorOptions['params'];
services: { callCluster: AlertExecutorOptions['params']['callCluster'] };
}) => Promise<void>;
const services: AlertServicesMock = alertsMock.createAlertServices();
services.callCluster.mockImplementation(async (_: string, { body, index }: any) => {
if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse;
const metric = body.query.bool.filter[1]?.exists.field;
if (body.aggs.groupings) {
if (body.aggs.groupings.composite.after) {
return mocks.compositeEndResponse;
}
if (metric === 'test.metric.2') {
return mocks.alternateCompositeResponse;
}
return mocks.basicCompositeResponse;
}
if (metric === 'test.metric.2') {
return mocks.alternateMetricResponse;
}
return mocks.basicMetricResponse;
});
services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => {
if (sourceId === 'alternate')
return {
id: 'alternate',
attributes: { metricAlias: 'alternatebeat-*' },
type,
references: [],
};
return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] };
});
services.callCluster.mockImplementation(async (_: string, { body, index }: any) => {
if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse;
const metric = body.query.bool.filter[1]?.exists.field;
if (body.aggs.groupings) {
if (body.aggs.groupings.composite.after) {
return mocks.compositeEndResponse;
}
if (metric === 'test.metric.2') {
return mocks.alternateCompositeResponse;
}
return mocks.basicCompositeResponse;
}
if (metric === 'test.metric.2') {
return mocks.alternateMetricResponse;
} else if (metric === 'test.metric.3') {
return mocks.emptyMetricResponse;
}
return mocks.basicMetricResponse;
});
services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => {
if (sourceId === 'alternate')
return {
id: 'alternate',
attributes: { metricAlias: 'alternatebeat-*' },
type,
references: [],
};
return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] };
});
const alertInstances = new Map<string, AlertTestInstance>();
services.alertInstanceFactory.mockImplementation((instanceID: string) => {
const alertInstance: AlertTestInstance = {
instance: alertsMock.createAlertInstanceFactory(),
actionQueue: [],
state: {},
};
alertInstances.set(instanceID, alertInstance);
alertInstance.instance.replaceState.mockImplementation((newState: any) => {
alertInstance.state = newState;
return alertInstance.instance;
});
alertInstance.instance.scheduleActions.mockImplementation((id: string, action: any) => {
alertInstance.actionQueue.push({ id, action });
return alertInstance.instance;
});
return alertInstance.instance;
});
function mostRecentAction(id: string) {
return alertInstances.get(id)!.actionQueue.pop();
}
function getState(id: string) {
return alertInstances.get(id)!.state;
}
const baseCriterion = {
aggType: 'avg',
metric: 'test.metric.1',
timeSize: 1,
timeUnit: 'm',
};

View file

@ -5,8 +5,6 @@
*/
import { mapValues } from 'lodash';
import { i18n } from '@kbn/i18n';
import { convertSavedObjectToSavedSourceConfiguration } from '../../sources/sources';
import { infraSourceConfigurationSavedObjectType } from '../../sources/saved_object_mappings';
import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types';
import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler';
import { getAllCompositeData } from '../../../utils/get_all_composite_data';
@ -22,9 +20,9 @@ import {
import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server';
import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds';
import { getDateHistogramOffset } from '../../snapshot/query_helpers';
import { InfraBackendLibs } from '../../infra_types';
const TOTAL_BUCKETS = 5;
const DEFAULT_INDEX_PATTERN = 'metricbeat-*';
interface Aggregation {
aggregatedIntervals: {
@ -76,6 +74,7 @@ const getParsedFilterQuery: (
export const getElasticsearchMetricQuery = (
{ metric, aggType, timeUnit, timeSize }: MetricExpressionParams,
timefield: string,
groupBy?: string,
filterQuery?: string
) => {
@ -109,7 +108,7 @@ export const getElasticsearchMetricQuery = (
const baseAggs = {
aggregatedIntervals: {
date_histogram: {
field: '@timestamp',
field: timefield,
fixed_interval: interval,
offset,
extended_bounds: {
@ -181,43 +180,23 @@ export const getElasticsearchMetricQuery = (
};
};
const getIndexPattern: (
services: AlertServices,
sourceId?: string
) => Promise<string> = async function({ savedObjectsClient }, sourceId = 'default') {
try {
const sourceConfiguration = await savedObjectsClient.get(
infraSourceConfigurationSavedObjectType,
sourceId
);
const { metricAlias } = convertSavedObjectToSavedSourceConfiguration(
sourceConfiguration
).configuration;
return metricAlias || DEFAULT_INDEX_PATTERN;
} catch (e) {
if (e.output.statusCode === 404) {
return DEFAULT_INDEX_PATTERN;
} else {
throw e;
}
}
};
const getMetric: (
services: AlertServices,
params: MetricExpressionParams,
index: string,
timefield: string,
groupBy: string | undefined,
filterQuery: string | undefined
) => Promise<Record<string, number>> = async function(
{ savedObjectsClient, callCluster },
{ callCluster },
params,
index,
timefield,
groupBy,
filterQuery
) {
const { aggType } = params;
const searchBody = getElasticsearchMetricQuery(params, groupBy, filterQuery);
const searchBody = getElasticsearchMetricQuery(params, timefield, groupBy, filterQuery);
try {
if (groupBy) {
@ -265,7 +244,7 @@ const comparatorMap = {
[Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b,
};
export const createMetricThresholdExecutor = (alertUUID: string) =>
export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: string) =>
async function({ services, params }: AlertExecutorOptions) {
const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as {
criteria: MetricExpressionParams[];
@ -275,11 +254,22 @@ export const createMetricThresholdExecutor = (alertUUID: string) =>
alertOnNoData: boolean;
};
const source = await libs.sources.getSourceConfiguration(
services.savedObjectsClient,
sourceId || 'default'
);
const config = source.configuration;
const alertResults = await Promise.all(
criteria.map(criterion =>
(async () => {
const index = await getIndexPattern(services, sourceId);
const currentValues = await getMetric(services, criterion, index, groupBy, filterQuery);
criteria.map(criterion => {
return (async () => {
const currentValues = await getMetric(
services,
criterion,
config.fields.timestamp,
config.metricAlias,
groupBy,
filterQuery
);
const { threshold, comparator } = criterion;
const comparisonFunction = comparatorMap[comparator];
return mapValues(currentValues, value => ({
@ -291,13 +281,14 @@ export const createMetricThresholdExecutor = (alertUUID: string) =>
isNoData: value === null,
isError: value === undefined,
}));
})()
)
})();
})
);
// Because each alert result has the same group definitions, just grap the groups from the first one.
const groups = Object.keys(alertResults[0]);
for (const group of groups) {
const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`);
const alertInstance = services.alertInstanceFactory(`${alertId}-${group}`);
// AND logic; all criteria must be across the threshold
const shouldAlertFire = alertResults.every(result => result[group].shouldFire);

View file

@ -6,11 +6,11 @@
import { i18n } from '@kbn/i18n';
import uuid from 'uuid';
import { schema } from '@kbn/config-schema';
import { PluginSetupContract } from '../../../../../alerting/server';
import { curry } from 'lodash';
import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer';
import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor';
import { InfraBackendLibs } from '../../infra_types';
import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types';
import { InfraBackendLibs } from '../../infra_types';
const oneOfLiterals = (arrayOfLiterals: Readonly<string[]>) =>
schema.string({
@ -18,17 +18,7 @@ const oneOfLiterals = (arrayOfLiterals: Readonly<string[]>) =>
arrayOfLiterals.includes(value) ? undefined : `must be one of ${arrayOfLiterals.join(' | ')}`,
});
export async function registerMetricThresholdAlertType(
alertingPlugin: PluginSetupContract,
libs: InfraBackendLibs
) {
if (!alertingPlugin) {
throw new Error(
'Cannot register metric threshold alert type. Both the actions and alerting plugins need to be enabled.'
);
}
const alertUUID = uuid.v4();
export function registerMetricThresholdAlertType(libs: InfraBackendLibs) {
const baseCriterion = {
threshold: schema.arrayOf(schema.number()),
comparator: oneOfLiterals(Object.values(Comparator)),
@ -70,21 +60,24 @@ export async function registerMetricThresholdAlertType(
}
);
alertingPlugin.registerType({
return {
id: METRIC_THRESHOLD_ALERT_TYPE_ID,
name: 'Metric threshold',
validate: {
params: schema.object({
criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])),
groupBy: schema.maybe(schema.string()),
filterQuery: schema.maybe(schema.string()),
sourceId: schema.string(),
alertOnNoData: schema.maybe(schema.boolean()),
}),
params: schema.object(
{
criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])),
groupBy: schema.maybe(schema.string()),
filterQuery: schema.maybe(schema.string()),
sourceId: schema.string(),
alertOnNoData: schema.maybe(schema.boolean()),
},
{ unknowns: 'allow' }
),
},
defaultActionGroupId: FIRED_ACTIONS.id,
actionGroups: [FIRED_ACTIONS],
executor: createMetricThresholdExecutor(alertUUID),
executor: curry(createMetricThresholdExecutor)(libs, uuid.v4()),
actionVariables: {
context: [
{ name: 'group', description: groupActionVariableDescription },
@ -92,5 +85,5 @@ export async function registerMetricThresholdAlertType(
{ name: 'reason', description: reasonActionVariableDescription },
],
},
});
};
}

View file

@ -6,13 +6,16 @@
import { PluginSetupContract } from '../../../../alerting/server';
import { registerMetricThresholdAlertType } from './metric_threshold/register_metric_threshold_alert_type';
import { registerMetricInventoryThresholdAlertType } from './inventory_metric_threshold/register_inventory_metric_threshold_alert_type';
import { registerLogThresholdAlertType } from './log_threshold/register_log_threshold_alert_type';
import { InfraBackendLibs } from '../infra_types';
const registerAlertTypes = (alertingPlugin: PluginSetupContract, libs: InfraBackendLibs) => {
if (alertingPlugin) {
const registerFns = [registerMetricThresholdAlertType, registerLogThresholdAlertType];
alertingPlugin.registerType(registerMetricThresholdAlertType(libs));
alertingPlugin.registerType(registerMetricInventoryThresholdAlertType(libs));
const registerFns = [registerLogThresholdAlertType];
registerFns.forEach(fn => {
fn(alertingPlugin, libs);
});

View file

@ -28,7 +28,7 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ
const sourceStatus = new InfraSourceStatus(new InfraElasticsearchSourceStatusAdapter(framework), {
sources,
});
const snapshot = new InfraSnapshot({ sources, framework });
const snapshot = new InfraSnapshot();
const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework });
const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework });

View file

@ -5,26 +5,23 @@
*/
import { uniq } from 'lodash';
import { RequestHandlerContext } from 'kibana/server';
import { InfraSnapshotRequestOptions } from './types';
import { getMetricsAggregations } from './query_helpers';
import { calculateMetricInterval } from '../../utils/calculate_metric_interval';
import { SnapshotModel, SnapshotModelMetricAggRT } from '../../../common/inventory_models/types';
import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter';
import { getDatasetForField } from '../../routes/metrics_explorer/lib/get_dataset_for_field';
import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api';
import { ESSearchClient } from '.';
export const createTimeRangeWithInterval = async (
framework: KibanaFramework,
requestContext: RequestHandlerContext,
client: ESSearchClient,
options: InfraSnapshotRequestOptions
): Promise<InfraTimerangeInput> => {
const aggregations = getMetricsAggregations(options);
const modules = await aggregationsToModules(framework, requestContext, aggregations, options);
const modules = await aggregationsToModules(client, aggregations, options);
const interval = Math.max(
(await calculateMetricInterval(
framework,
requestContext,
client,
{
indexPattern: options.sourceConfiguration.metricAlias,
timestampField: options.sourceConfiguration.fields.timestamp,
@ -43,8 +40,7 @@ export const createTimeRangeWithInterval = async (
};
const aggregationsToModules = async (
framework: KibanaFramework,
requestContext: RequestHandlerContext,
client: ESSearchClient,
aggregations: SnapshotModel,
options: InfraSnapshotRequestOptions
): Promise<string[]> => {
@ -59,12 +55,7 @@ const aggregationsToModules = async (
const fields = await Promise.all(
uniqueFields.map(
async field =>
await getDatasetForField(
framework,
requestContext,
field as string,
options.sourceConfiguration.metricAlias
)
await getDatasetForField(client, field as string, options.sourceConfiguration.metricAlias)
)
);
return fields.filter(f => f) as string[];

View file

@ -3,11 +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 { RequestHandlerContext } from 'src/core/server';
import { InfraDatabaseSearchResponse } from '../adapters/framework';
import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter';
import { InfraSources } from '../sources';
import { InfraDatabaseSearchResponse, CallWithRequestParams } from '../adapters/framework';
import { JsonObject } from '../../../common/typed_json';
import { SNAPSHOT_COMPOSITE_REQUEST_SIZE } from './constants';
@ -31,36 +27,26 @@ import { InfraSnapshotRequestOptions } from './types';
import { createTimeRangeWithInterval } from './create_timerange_with_interval';
import { SnapshotNode } from '../../../common/http_api/snapshot_api';
export type ESSearchClient = <Hit = {}, Aggregation = undefined>(
options: CallWithRequestParams
) => Promise<InfraDatabaseSearchResponse<Hit, Aggregation>>;
export class InfraSnapshot {
constructor(private readonly libs: { sources: InfraSources; framework: KibanaFramework }) {}
public async getNodes(
requestContext: RequestHandlerContext,
client: ESSearchClient,
options: InfraSnapshotRequestOptions
): Promise<{ nodes: SnapshotNode[]; interval: string }> {
// Both requestGroupedNodes and requestNodeMetrics may send several requests to elasticsearch
// in order to page through the results of their respective composite aggregations.
// Both chains of requests are supposed to run in parallel, and their results be merged
// when they have both been completed.
const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(
this.libs.framework,
requestContext,
options
);
const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, options);
const optionsWithTimerange = { ...options, timerange: timeRangeWithIntervalApplied };
const groupedNodesPromise = requestGroupedNodes(
requestContext,
optionsWithTimerange,
this.libs.framework
);
const nodeMetricsPromise = requestNodeMetrics(
requestContext,
optionsWithTimerange,
this.libs.framework
);
const groupedNodesPromise = requestGroupedNodes(client, optionsWithTimerange);
const nodeMetricsPromise = requestNodeMetrics(client, optionsWithTimerange);
const groupedNodeBuckets = await groupedNodesPromise;
const nodeMetricBuckets = await nodeMetricsPromise;
return {
nodes: mergeNodeBuckets(groupedNodeBuckets, nodeMetricBuckets, options),
interval: timeRangeWithIntervalApplied.interval,
@ -77,15 +63,12 @@ const handleAfterKey = createAfterKeyHandler(
input => input?.aggregations?.nodes?.after_key
);
const callClusterFactory = (framework: KibanaFramework, requestContext: RequestHandlerContext) => (
opts: any
) =>
framework.callWithRequest<{}, InfraSnapshotAggregationResponse>(requestContext, 'search', opts);
const callClusterFactory = (search: ESSearchClient) => (opts: any) =>
search<{}, InfraSnapshotAggregationResponse>(opts);
const requestGroupedNodes = async (
requestContext: RequestHandlerContext,
options: InfraSnapshotRequestOptions,
framework: KibanaFramework
client: ESSearchClient,
options: InfraSnapshotRequestOptions
): Promise<InfraSnapshotNodeGroupByBucket[]> => {
const inventoryModel = findInventoryModel(options.nodeType);
const query = {
@ -124,13 +107,12 @@ const requestGroupedNodes = async (
return await getAllCompositeData<
InfraSnapshotAggregationResponse,
InfraSnapshotNodeGroupByBucket
>(callClusterFactory(framework, requestContext), query, bucketSelector, handleAfterKey);
>(callClusterFactory(client), query, bucketSelector, handleAfterKey);
};
const requestNodeMetrics = async (
requestContext: RequestHandlerContext,
options: InfraSnapshotRequestOptions,
framework: KibanaFramework
client: ESSearchClient,
options: InfraSnapshotRequestOptions
): Promise<InfraSnapshotNodeMetricsBucket[]> => {
const index =
options.metric.type === 'logRate'
@ -175,7 +157,7 @@ const requestNodeMetrics = async (
return await getAllCompositeData<
InfraSnapshotAggregationResponse,
InfraSnapshotNodeMetricsBucket
>(callClusterFactory(framework, requestContext), query, bucketSelector, handleAfterKey);
>(callClusterFactory(client), query, bucketSelector, handleAfterKey);
};
// buckets can be InfraSnapshotNodeGroupByBucket[] or InfraSnapshotNodeMetricsBucket[]

View file

@ -9,7 +9,7 @@ import { failure } from 'io-ts/lib/PathReporter';
import { identity, constant } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import { map, fold } from 'fp-ts/lib/Either';
import { RequestHandlerContext, SavedObjectsClientContract } from 'src/core/server';
import { SavedObjectsClientContract } from 'src/core/server';
import { defaultSourceConfiguration } from './defaults';
import { NotFoundError } from './errors';
import { infraSourceConfigurationSavedObjectType } from './saved_object_mappings';
@ -41,7 +41,6 @@ export class InfraSources {
sourceId: string
): Promise<InfraSource> {
const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration();
const savedSourceConfiguration = await this.getInternalSourceConfiguration(sourceId)
.then(internalSourceConfiguration => ({
id: sourceId,
@ -79,10 +78,12 @@ export class InfraSources {
return savedSourceConfiguration;
}
public async getAllSourceConfigurations(requestContext: RequestHandlerContext) {
public async getAllSourceConfigurations(savedObjectsClient: SavedObjectsClientContract) {
const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration();
const savedSourceConfigurations = await this.getAllSavedSourceConfigurations(requestContext);
const savedSourceConfigurations = await this.getAllSavedSourceConfigurations(
savedObjectsClient
);
return savedSourceConfigurations.map(savedSourceConfiguration => ({
...savedSourceConfiguration,
@ -94,7 +95,7 @@ export class InfraSources {
}
public async createSourceConfiguration(
requestContext: RequestHandlerContext,
savedObjectsClient: SavedObjectsClientContract,
sourceId: string,
source: InfraSavedSourceConfiguration
) {
@ -106,7 +107,7 @@ export class InfraSources {
);
const createdSourceConfiguration = convertSavedObjectToSavedSourceConfiguration(
await requestContext.core.savedObjects.client.create(
await savedObjectsClient.create(
infraSourceConfigurationSavedObjectType,
pickSavedSourceConfiguration(newSourceConfiguration) as any,
{ id: sourceId }
@ -122,22 +123,22 @@ export class InfraSources {
};
}
public async deleteSourceConfiguration(requestContext: RequestHandlerContext, sourceId: string) {
await requestContext.core.savedObjects.client.delete(
infraSourceConfigurationSavedObjectType,
sourceId
);
public async deleteSourceConfiguration(
savedObjectsClient: SavedObjectsClientContract,
sourceId: string
) {
await savedObjectsClient.delete(infraSourceConfigurationSavedObjectType, sourceId);
}
public async updateSourceConfiguration(
requestContext: RequestHandlerContext,
savedObjectsClient: SavedObjectsClientContract,
sourceId: string,
sourceProperties: InfraSavedSourceConfiguration
) {
const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration();
const { configuration, version } = await this.getSourceConfiguration(
requestContext.core.savedObjects.client,
savedObjectsClient,
sourceId
);
@ -147,7 +148,7 @@ export class InfraSources {
);
const updatedSourceConfiguration = convertSavedObjectToSavedSourceConfiguration(
await requestContext.core.savedObjects.client.update(
await savedObjectsClient.update(
infraSourceConfigurationSavedObjectType,
sourceId,
pickSavedSourceConfiguration(updatedSourceConfigurationAttributes) as any,
@ -213,8 +214,8 @@ export class InfraSources {
return convertSavedObjectToSavedSourceConfiguration(savedObject);
}
private async getAllSavedSourceConfigurations(requestContext: RequestHandlerContext) {
const savedObjects = await requestContext.core.savedObjects.client.find({
private async getAllSavedSourceConfigurations(savedObjectsClient: SavedObjectsClientContract) {
const savedObjects = await savedObjectsClient.find({
type: infraSourceConfigurationSavedObjectType,
});

View file

@ -109,7 +109,7 @@ export class InfraServerPlugin {
sources,
}
);
const snapshot = new InfraSnapshot({ sources, framework });
const snapshot = new InfraSnapshot();
const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework });
const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework });

View file

@ -82,12 +82,12 @@ export const initLogSourceConfigurationRoutes = ({ framework, sources }: InfraBa
const sourceConfigurationExists = sourceConfiguration.origin === 'stored';
const patchedSourceConfiguration = await (sourceConfigurationExists
? sources.updateSourceConfiguration(
requestContext,
requestContext.core.savedObjects.client,
sourceId,
patchedSourceConfigurationProperties
)
: sources.createSourceConfiguration(
requestContext,
requestContext.core.savedObjects.client,
sourceId,
patchedSourceConfigurationProperties
));

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { RequestHandlerContext } from 'kibana/server';
import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter';
import { ESSearchClient } from '../../../lib/snapshot';
interface EventDatasetHit {
_source: {
@ -16,8 +15,7 @@ interface EventDatasetHit {
}
export const getDatasetForField = async (
framework: KibanaFramework,
requestContext: RequestHandlerContext,
client: ESSearchClient,
field: string,
indexPattern: string
) => {
@ -33,11 +31,8 @@ export const getDatasetForField = async (
},
};
const response = await framework.callWithRequest<EventDatasetHit>(
requestContext,
'search',
params
);
const response = await client<EventDatasetHit>(params);
if (response.hits.total.value === 0) {
return null;
}

View file

@ -17,6 +17,10 @@ import { createMetricModel } from './create_metrics_model';
import { JsonObject } from '../../../../common/typed_json';
import { calculateMetricInterval } from '../../../utils/calculate_metric_interval';
import { getDatasetForField } from './get_dataset_for_field';
import {
CallWithRequestParams,
InfraDatabaseSearchResponse,
} from '../../../lib/adapters/framework';
export const populateSeriesWithTSVBData = (
request: KibanaRequest,
@ -52,17 +56,21 @@ export const populateSeriesWithTSVBData = (
}
const timerange = { min: options.timerange.from, max: options.timerange.to };
const client = <Hit = {}, Aggregation = undefined>(
opts: CallWithRequestParams
): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> =>
framework.callWithRequest(requestContext, 'search', opts);
// Create the TSVB model based on the request options
const model = createMetricModel(options);
const modules = await Promise.all(
uniq(options.metrics.filter(m => m.field)).map(
async m =>
await getDatasetForField(framework, requestContext, m.field as string, options.indexPattern)
async m => await getDatasetForField(client, m.field as string, options.indexPattern)
)
);
const calculatedInterval = await calculateMetricInterval(
framework,
requestContext,
client,
{
indexPattern: options.indexPattern,
timestampField: options.timerange.field,

View file

@ -13,6 +13,7 @@ import { UsageCollector } from '../../usage/usage_collector';
import { parseFilterQuery } from '../../utils/serialized_query';
import { SnapshotRequestRT, SnapshotNodeResponseRT } from '../../../common/http_api/snapshot_api';
import { throwErrors } from '../../../common/runtime_types';
import { CallWithRequestParams, InfraDatabaseSearchResponse } from '../../lib/adapters/framework';
const escapeHatch = schema.object({}, { unknowns: 'allow' });
@ -57,7 +58,13 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => {
metric,
timerange,
};
const nodesWithInterval = await libs.snapshot.getNodes(requestContext, options);
const searchES = <Hit = {}, Aggregation = undefined>(
opts: CallWithRequestParams
): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> =>
framework.callWithRequest(requestContext, 'search', opts);
const nodesWithInterval = await libs.snapshot.getNodes(searchES, options);
return response.ok({
body: SnapshotNodeResponseRT.encode(nodesWithInterval),
});

View file

@ -4,10 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { RequestHandlerContext } from 'src/core/server';
// import { RequestHandlerContext } from 'src/core/server';
import { findInventoryModel } from '../../common/inventory_models';
import { KibanaFramework } from '../lib/adapters/framework/kibana_framework_adapter';
// import { KibanaFramework } from '../lib/adapters/framework/kibana_framework_adapter';
import { InventoryItemType } from '../../common/inventory_models/types';
import { ESSearchClient } from '../lib/snapshot';
interface Options {
indexPattern: string;
@ -23,8 +24,7 @@ interface Options {
* This is useful for visualizing metric modules like s3 that only send metrics once per day.
*/
export const calculateMetricInterval = async (
framework: KibanaFramework,
requestContext: RequestHandlerContext,
client: ESSearchClient,
options: Options,
modules?: string[],
nodeType?: InventoryItemType // TODO: check that this type still makes sense
@ -73,11 +73,7 @@ export const calculateMetricInterval = async (
},
};
const resp = await framework.callWithRequest<{}, PeriodAggregationData>(
requestContext,
'search',
query
);
const resp = await client<{}, PeriodAggregationData>(query);
// if ES doesn't return an aggregations key, something went seriously wrong.
if (!resp.aggregations) {

View file

@ -32,7 +32,7 @@ export default function({ getService }: FtrProviderContext) {
describe('querying the entire infrastructure', () => {
for (const aggType of aggs) {
it(`should work with the ${aggType} aggregator`, async () => {
const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType));
const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType), '@timestamp');
const result = await client.search({
index,
body: searchBody,
@ -45,6 +45,7 @@ export default function({ getService }: FtrProviderContext) {
it('should work with a filterQuery', async () => {
const searchBody = getElasticsearchMetricQuery(
getSearchParams('avg'),
'@timestamp',
undefined,
'{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}'
);
@ -59,6 +60,7 @@ export default function({ getService }: FtrProviderContext) {
it('should work with a filterQuery in KQL format', async () => {
const searchBody = getElasticsearchMetricQuery(
getSearchParams('avg'),
'@timestamp',
undefined,
'"agent.hostname":"foo"'
);
@ -74,7 +76,11 @@ export default function({ getService }: FtrProviderContext) {
describe('querying with a groupBy parameter', () => {
for (const aggType of aggs) {
it(`should work with the ${aggType} aggregator`, async () => {
const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType), 'agent.id');
const searchBody = getElasticsearchMetricQuery(
getSearchParams(aggType),
'@timestamp',
'agent.id'
);
const result = await client.search({
index,
body: searchBody,
@ -87,6 +93,7 @@ export default function({ getService }: FtrProviderContext) {
it('should work with a filterQuery', async () => {
const searchBody = getElasticsearchMetricQuery(
getSearchParams('avg'),
'@timestamp',
'agent.id',
'{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}'
);