[Metrics UI] Enhance Inventory View Tooltips (#69757)

* [Metrics UI] Enhance Inventory View Tooltips

* Fixing typos

* Removing toMetricOpt

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Chris Cowan 2020-07-01 09:45:21 -07:00 committed by GitHub
parent 652b11f270
commit b7f33b94a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 815 additions and 174 deletions

View file

@ -34,7 +34,7 @@ export const SnapshotNodeMetricRT = rt.intersection([
SnapshotNodeMetricOptionalRT,
]);
export const SnapshotNodeRT = rt.type({
metric: SnapshotNodeMetricRT,
metrics: rt.array(SnapshotNodeMetricRT),
path: rt.array(SnapshotNodePathRT),
});
@ -97,7 +97,7 @@ export const SnapshotMetricInputRT = rt.union([
export const SnapshotRequestRT = rt.intersection([
rt.type({
timerange: InfraTimerangeInputRT,
metric: SnapshotMetricInputRT,
metrics: rt.array(SnapshotMetricInputRT),
groupBy: SnapshotGroupByRT,
nodeType: ItemTypeRT,
sourceId: rt.string,

View file

@ -30,4 +30,5 @@ export const awsEC2: InventoryModel = {
ip: 'aws.ec2.instance.public.ip',
},
requiredMetrics: ['awsEC2CpuUtilization', 'awsEC2NetworkTraffic', 'awsEC2DiskIOBytes'],
tooltipMetrics: ['cpu', 'rx', 'tx'],
};

View file

@ -35,4 +35,11 @@ export const awsRDS: InventoryModel = {
'awsRDSActiveTransactions',
'awsRDSLatency',
],
tooltipMetrics: [
'cpu',
'rdsLatency',
'rdsConnections',
'rdsQueriesExecuted',
'rdsActiveTransactions',
],
};

View file

@ -35,4 +35,11 @@ export const awsS3: InventoryModel = {
'awsS3DownloadBytes',
'awsS3UploadBytes',
],
tooltipMetrics: [
's3BucketSize',
's3NumberOfObjects',
's3TotalRequests',
's3UploadBytes',
's3DownloadBytes',
],
};

View file

@ -35,4 +35,11 @@ export const awsSQS: InventoryModel = {
'awsSQSMessagesEmpty',
'awsSQSOldestMessage',
],
tooltipMetrics: [
'sqsMessagesVisible',
'sqsMessagesDelayed',
'sqsMessagesEmpty',
'sqsMessagesSent',
'sqsOldestMessage',
],
};

View file

@ -37,4 +37,5 @@ export const container: InventoryModel = {
'containerDiskIOBytes',
'containerDiskIOOps',
],
tooltipMetrics: ['cpu', 'memory', 'rx', 'tx'],
};

View file

@ -47,4 +47,5 @@ export const host: InventoryModel = {
...awsRequiredMetrics,
...nginxRequireMetrics,
],
tooltipMetrics: ['cpu', 'memory', 'tx', 'rx'],
};

View file

@ -5,6 +5,7 @@
*/
import { i18n } from '@kbn/i18n';
import { SnapshotMetricType } from './types';
export const CPUUsage = i18n.translate('xpack.infra.waffle.metricOptions.cpuUsageText', {
defaultMessage: 'CPU usage',
});
@ -68,3 +69,81 @@ export const fieldToName = (field: string) => {
};
return LOOKUP[field] || field;
};
export const SNAPSHOT_METRIC_TRANSLATIONS = {
cpu: i18n.translate('xpack.infra.waffle.metricOptions.cpuUsageText', {
defaultMessage: 'CPU usage',
}),
memory: i18n.translate('xpack.infra.waffle.metricOptions.memoryUsageText', {
defaultMessage: 'Memory usage',
}),
rx: i18n.translate('xpack.infra.waffle.metricOptions.inboundTrafficText', {
defaultMessage: 'Inbound traffic',
}),
tx: i18n.translate('xpack.infra.waffle.metricOptions.outboundTrafficText', {
defaultMessage: 'Outbound traffic',
}),
logRate: i18n.translate('xpack.infra.waffle.metricOptions.hostLogRateText', {
defaultMessage: 'Log rate',
}),
load: i18n.translate('xpack.infra.waffle.metricOptions.loadText', {
defaultMessage: 'Load',
}),
count: i18n.translate('xpack.infra.waffle.metricOptions.countText', {
defaultMessage: 'Count',
}),
diskIOReadBytes: i18n.translate('xpack.infra.waffle.metricOptions.diskIOReadBytes', {
defaultMessage: 'Disk Reads',
}),
diskIOWriteBytes: i18n.translate('xpack.infra.waffle.metricOptions.diskIOWriteBytes', {
defaultMessage: 'Disk Writes',
}),
s3BucketSize: i18n.translate('xpack.infra.waffle.metricOptions.s3BucketSize', {
defaultMessage: 'Bucket Size',
}),
s3TotalRequests: i18n.translate('xpack.infra.waffle.metricOptions.s3TotalRequests', {
defaultMessage: 'Total Requests',
}),
s3NumberOfObjects: i18n.translate('xpack.infra.waffle.metricOptions.s3NumberOfObjects', {
defaultMessage: 'Number of Objects',
}),
s3DownloadBytes: i18n.translate('xpack.infra.waffle.metricOptions.s3DownloadBytes', {
defaultMessage: 'Downloads (Bytes)',
}),
s3UploadBytes: i18n.translate('xpack.infra.waffle.metricOptions.s3UploadBytes', {
defaultMessage: 'Uploads (Bytes)',
}),
rdsConnections: i18n.translate('xpack.infra.waffle.metricOptions.rdsConnections', {
defaultMessage: 'Connections',
}),
rdsQueriesExecuted: i18n.translate('xpack.infra.waffle.metricOptions.rdsQueriesExecuted', {
defaultMessage: 'Queries Executed',
}),
rdsActiveTransactions: i18n.translate('xpack.infra.waffle.metricOptions.rdsActiveTransactions', {
defaultMessage: 'Active Transactions',
}),
rdsLatency: i18n.translate('xpack.infra.waffle.metricOptions.rdsLatency', {
defaultMessage: 'Latency',
}),
sqsMessagesVisible: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesVisible', {
defaultMessage: 'Messages Available',
}),
sqsMessagesDelayed: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesDelayed', {
defaultMessage: 'Messages Delayed',
}),
sqsMessagesSent: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesSent', {
defaultMessage: 'Messages Added',
}),
sqsMessagesEmpty: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesEmpty', {
defaultMessage: 'Messages Returned Empty',
}),
sqsOldestMessage: i18n.translate('xpack.infra.waffle.metricOptions.sqsOldestMessage', {
defaultMessage: 'Oldest Message',
}),
} as Record<SnapshotMetricType, string>;

View file

@ -37,4 +37,5 @@ export const pod: InventoryModel = {
'podNetworkTraffic',
...nginxRequiredMetrics,
],
tooltipMetrics: ['cpu', 'memory', 'rx', 'tx'],
};

View file

@ -351,4 +351,5 @@ export interface InventoryModel {
};
metrics: InventoryMetrics;
requiredMetrics: InventoryMetric[];
tooltipMetrics: SnapshotMetricType[];
}

View file

@ -22,7 +22,7 @@ export interface InfraWaffleMapNode {
name: string;
ip?: string | null;
path: SnapshotNodePath[];
metric: SnapshotNodeMetric;
metrics: SnapshotNodeMetric[];
}
export type InfraWaffleMapGroup = InfraWaffleMapGroupOfNodes | InfraWaffleMapGroupOfGroups;

View file

@ -9,7 +9,8 @@ import { useInterval } from 'react-use';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { convertIntervalToString } from '../../../../utils/convert_interval_to_string';
import { NodesOverview, calculateBoundsFromNodes } from './nodes_overview';
import { NodesOverview } from './nodes_overview';
import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes';
import { PageContent } from '../../../../components/page';
import { useSnapshot } from '../hooks/use_snaphot';
import { useWaffleTimeContext } from '../hooks/use_waffle_time';
@ -48,7 +49,7 @@ export const Layout = () => {
const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext();
const { loading, nodes, reload, interval } = useSnapshot(
filterQueryAsJson,
metric,
[metric],
groupBy,
nodeType,
sourceId,

View file

@ -5,7 +5,6 @@
*/
import { i18n } from '@kbn/i18n';
import { max, min } from 'lodash';
import React, { useCallback } from 'react';
import { InventoryItemType } from '../../../../../common/inventory_models/types';
@ -16,6 +15,7 @@ import { InfraLoadingPanel } from '../../../../components/loading';
import { Map } from './waffle/map';
import { TableView } from './table_view';
import { SnapshotNode } from '../../../../../common/http_api/snapshot_api';
import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes';
export interface KueryFilterQuery {
kind: 'kuery';
@ -36,18 +36,6 @@ interface Props {
formatter: InfraFormatter;
}
export const calculateBoundsFromNodes = (nodes: SnapshotNode[]): InfraWaffleMapBounds => {
const maxValues = nodes.map((node) => node.metric.max);
const minValues = nodes.map((node) => node.metric.value);
// if there is only one value then we need to set the bottom range to zero for min
// otherwise the legend will look silly since both values are the same for top and
// bottom.
if (minValues.length === 1) {
minValues.unshift(0);
}
return { min: min(minValues) || 0, max: max(maxValues) || 0 };
};
export const NodesOverview = ({
autoBounds,
boundsOverride,

View file

@ -7,7 +7,7 @@
import { EuiButtonEmpty, EuiInMemoryTable, EuiToolTip, EuiBasicTableColumn } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { last } from 'lodash';
import { last, first } from 'lodash';
import React, { useState, useCallback, useEffect } from 'react';
import { createWaffleMapNode } from '../lib/nodes_to_wafflemap';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../lib/lib';
@ -142,6 +142,7 @@ export const TableView = (props: Props) => {
const items = nodes.map((node) => {
const name = last(node.path);
const metric = first(node.metrics);
return {
name: (name && name.label) || 'unknown',
...getGroupPaths(node.path).reduce(
@ -151,9 +152,9 @@ export const TableView = (props: Props) => {
}),
{}
),
value: node.metric.value,
avg: node.metric.avg,
max: node.metric.max,
value: (metric && metric.value) || 0,
avg: (metric && metric.avg) || 0,
max: (metric && metric.max) || 0,
node: createWaffleMapNode(node),
};
});

View file

@ -0,0 +1,80 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConditionalToolTip should just work 1`] = `
<div
data-test-subj="conditionalTooltipContent"
style={
Object {
"minWidth": 200,
}
}
>
<div
style={
Object {
"borderBottom": "1px solid #98a2b3",
"marginBottom": "4px",
"paddingBottom": "4px",
}
}
>
host-01
</div>
<EuiFlexGroup
gutterSize="none"
>
<EuiFlexItem
grow={1}
>
CPU usage
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
10%
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
gutterSize="none"
>
<EuiFlexItem
grow={1}
>
Memory usage
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
80%
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
gutterSize="none"
>
<EuiFlexItem
grow={1}
>
Outbound traffic
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
8Mbit/s
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
gutterSize="none"
>
<EuiFlexItem
grow={1}
>
Inbound traffic
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
8Mbit/s
</EuiFlexItem>
</EuiFlexGroup>
</div>
`;

View file

@ -0,0 +1,155 @@
/*
* 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 from 'react';
import { mount } from 'enzyme';
// import { act } from 'react-dom/test-utils';
import { EuiThemeProvider } from '../../../../../../../observability/public';
import { EuiToolTip } from '@elastic/eui';
import { ConditionalToolTip } from './conditional_tooltip';
import {
InfraWaffleMapNode,
InfraWaffleMapOptions,
InfraFormatterType,
} from '../../../../../lib/lib';
jest.mock('../../../../../containers/source', () => ({
useSourceContext: () => ({ sourceId: 'default' }),
}));
jest.mock('../../hooks/use_snaphot');
import { useSnapshot } from '../../hooks/use_snaphot';
const mockedUseSnapshot = useSnapshot as jest.Mock<ReturnType<typeof useSnapshot>>;
const NODE: InfraWaffleMapNode = {
pathId: 'host-01',
id: 'host-01',
name: 'host-01',
path: [{ value: 'host-01', label: 'host-01' }],
metrics: [{ name: 'cpu' }],
};
const OPTIONS: InfraWaffleMapOptions = {
formatter: InfraFormatterType.percent,
formatTemplate: '{value}',
metric: { type: 'cpu' },
groupBy: [],
legend: {
type: 'steppedGradient',
rules: [],
},
sort: { by: 'value', direction: 'desc' },
};
export const nextTick = () => new Promise((res) => process.nextTick(res));
const ChildComponent = () => <div>child</div>;
describe('ConditionalToolTip', () => {
afterEach(() => {
mockedUseSnapshot.mockReset();
});
function createWrapper(currentTime: number = Date.now(), hidden: boolean = false) {
return mount(
<EuiThemeProvider darkMode={false}>
<ConditionalToolTip
currentTime={currentTime}
hidden={hidden}
node={NODE}
options={OPTIONS}
formatter={(v: number) => `${v}`}
nodeType="host"
>
<ChildComponent />
</ConditionalToolTip>
</EuiThemeProvider>
);
}
it('should return children when hidden', () => {
mockedUseSnapshot.mockReturnValue({
nodes: [],
error: null,
loading: true,
interval: '',
reload: jest.fn(() => Promise.resolve()),
});
const currentTime = Date.now();
const wrapper = createWrapper(currentTime, true);
expect(wrapper.find(ChildComponent).exists()).toBeTruthy();
});
it('should just work', () => {
jest.useFakeTimers();
const reloadMock = jest.fn(() => Promise.resolve());
mockedUseSnapshot.mockReturnValue({
nodes: [
{
path: [{ label: 'host-01', value: 'host-01', ip: '192.168.1.10' }],
metrics: [
{ name: 'cpu', value: 0.1, avg: 0.4, max: 0.7 },
{ name: 'memory', value: 0.8, avg: 0.8, max: 1 },
{ name: 'tx', value: 1000000, avg: 1000000, max: 1000000 },
{ name: 'rx', value: 1000000, avg: 1000000, max: 1000000 },
],
},
],
error: null,
loading: false,
interval: '60s',
reload: reloadMock,
});
const currentTime = Date.now();
const wrapper = createWrapper(currentTime, false);
expect(wrapper.find(ChildComponent).exists()).toBeTruthy();
expect(wrapper.find(EuiToolTip).exists()).toBeTruthy();
const expectedQuery = JSON.stringify({
bool: {
filter: {
match_phrase: { 'host.name': 'host-01' },
},
},
});
const expectedMetrics = [{ type: 'cpu' }, { type: 'memory' }, { type: 'tx' }, { type: 'rx' }];
expect(mockedUseSnapshot).toBeCalledWith(
expectedQuery,
expectedMetrics,
[],
'host',
'default',
currentTime,
'',
'',
false
);
wrapper.find('[data-test-subj~="conditionalTooltipMouseHandler"]').simulate('mouseOver');
wrapper.find(EuiToolTip).simulate('mouseOver');
jest.advanceTimersByTime(500);
expect(reloadMock).toHaveBeenCalled();
expect(wrapper.find(EuiToolTip).props().content).toMatchSnapshot();
});
it('should not load data if mouse out before 200 ms', () => {
jest.useFakeTimers();
const reloadMock = jest.fn(() => Promise.resolve());
mockedUseSnapshot.mockReturnValue({
nodes: [],
error: null,
loading: true,
interval: '',
reload: reloadMock,
});
const currentTime = Date.now();
const wrapper = createWrapper(currentTime, false);
expect(wrapper.find(ChildComponent).exists()).toBeTruthy();
expect(wrapper.find(EuiToolTip).exists()).toBeTruthy();
wrapper.find('[data-test-subj~="conditionalTooltipMouseHandler"]').simulate('mouseOver');
jest.advanceTimersByTime(100);
wrapper.find('[data-test-subj~="conditionalTooltipMouseHandler"]').simulate('mouseOut');
jest.advanceTimersByTime(200);
expect(reloadMock).not.toHaveBeenCalled();
});
});

View file

@ -3,18 +3,117 @@
* 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 from 'react';
import { EuiToolTip, EuiToolTipProps } from '@elastic/eui';
import { omit } from 'lodash';
import React, { useCallback, useState, useEffect } from 'react';
import { EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { first } from 'lodash';
import { withTheme, EuiTheme } from '../../../../../../../observability/public';
import { useSourceContext } from '../../../../../containers/source';
import { findInventoryModel } from '../../../../../../common/inventory_models';
import {
InventoryItemType,
SnapshotMetricType,
} from '../../../../../../common/inventory_models/types';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib';
import { useSnapshot } from '../../hooks/use_snaphot';
import { createInventoryMetricFormatter } from '../../lib/create_inventory_metric_formatter';
import { SNAPSHOT_METRIC_TRANSLATIONS } from '../../../../../../common/inventory_models/intl_strings';
interface Props extends EuiToolTipProps {
export interface Props {
currentTime: number;
hidden: boolean;
node: InfraWaffleMapNode;
options: InfraWaffleMapOptions;
formatter: (val: number) => string;
children: React.ReactElement;
nodeType: InventoryItemType;
theme: EuiTheme | undefined;
}
export const ConditionalToolTip = (props: Props) => {
if (props.hidden) {
return props.children;
export const ConditionalToolTip = withTheme(
({ theme, hidden, node, children, nodeType, currentTime }: Props) => {
const { sourceId } = useSourceContext();
const [timer, setTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const model = findInventoryModel(nodeType);
const requestMetrics = model.tooltipMetrics.map((type) => ({ type })) as Array<{
type: SnapshotMetricType;
}>;
const query = JSON.stringify({
bool: {
filter: {
match_phrase: { [model.fields.id]: node.id },
},
},
});
const { nodes, reload } = useSnapshot(
query,
requestMetrics,
[],
nodeType,
sourceId,
currentTime,
'',
'',
false // Doesn't send request until reload() is called
);
const handleDataLoad = useCallback(() => {
const id = setTimeout(reload, 200);
setTimer(id);
}, [reload]);
const cancelDataLoad = useCallback(() => {
return (timer && clearTimeout(timer)) || void 0;
}, [timer]);
useEffect(() => {
return cancelDataLoad;
}, [timer, cancelDataLoad]);
if (hidden) {
return children;
}
const dataNode = first(nodes);
const metrics = (dataNode && dataNode.metrics) || [];
const content = (
<div style={{ minWidth: 200 }} data-test-subj="conditionalTooltipContent">
<div
style={{
borderBottom: `1px solid ${theme?.eui.euiColorMediumShade}`,
paddingBottom: theme?.eui.paddingSizes.xs,
marginBottom: theme?.eui.paddingSizes.xs,
}}
>
{node.name}
</div>
{metrics.map((metric) => {
const name = SNAPSHOT_METRIC_TRANSLATIONS[metric.name] || metric.name;
const formatter = createInventoryMetricFormatter({ type: metric.name });
return (
<EuiFlexGroup gutterSize="none" key={metric.name}>
<EuiFlexItem grow={1}>{name}</EuiFlexItem>
<EuiFlexItem grow={false}>
{(metric.value && formatter(metric.value)) || '-'}
</EuiFlexItem>
</EuiFlexGroup>
);
})}
</div>
);
return (
<EuiToolTip delay="regular" position="right" content={content}>
<div
data-test-subj="conditionalTooltipMouseHandler"
onMouseOver={handleDataLoad}
onFocus={handleDataLoad}
onMouseOut={cancelDataLoad}
onBlur={cancelDataLoad}
>
{children}
</div>
</EuiToolTip>
);
}
const propsWithoutHidden = omit(props, 'hidden');
return <EuiToolTip {...propsWithoutHidden}>{props.children}</EuiToolTip>;
};
);

View file

@ -9,6 +9,7 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { first } from 'lodash';
import { ConditionalToolTip } from './conditional_tooltip';
import { euiStyled } from '../../../../../../../observability/public';
import {
@ -41,7 +42,7 @@ export const Node = class extends React.PureComponent<Props, State> {
public render() {
const { nodeType, node, options, squareSize, bounds, formatter, currentTime } = this.props;
const { isPopoverOpen } = this.state;
const { metric } = node;
const metric = first(node.metrics);
const valueMode = squareSize > 70;
const ellipsisMode = squareSize > 30;
const rawValue = (metric && metric.value) || 0;
@ -62,10 +63,12 @@ export const Node = class extends React.PureComponent<Props, State> {
popoverPosition="downCenter"
>
<ConditionalToolTip
delay="regular"
currentTime={currentTime}
formatter={formatter}
hidden={isPopoverOpen}
position="top"
content={`${node.name} | ${value}`}
node={node}
options={options}
nodeType={nodeType}
>
<NodeContainer
data-test-subj="nodeContainer"

View file

@ -7,6 +7,7 @@
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import { useEffect } from 'react';
import { throwErrors, createPlainError } from '../../../../../common/runtime_types';
import { useHTTPRequest } from '../../../../hooks/use_http_request';
import {
@ -23,13 +24,14 @@ import {
export function useSnapshot(
filterQuery: string | null | undefined,
metric: { type: SnapshotMetricType },
metrics: Array<{ type: SnapshotMetricType }>,
groupBy: SnapshotGroupBy,
nodeType: InventoryItemType,
sourceId: string,
currentTime: number,
accountId: string,
region: string
region: string,
sendRequestImmediatly = true
) {
const decodeResponse = (response: any) => {
return pipe(
@ -49,7 +51,7 @@ export function useSnapshot(
'/api/metrics/snapshot',
'POST',
JSON.stringify({
metric,
metrics,
groupBy,
nodeType,
timerange,
@ -62,6 +64,14 @@ export function useSnapshot(
decodeResponse
);
useEffect(() => {
(async () => {
if (sendRequestImmediatly) {
await makeRequest();
}
})();
}, [makeRequest, sendRequestImmediatly]);
return {
error: (error && error.message) || null,
loading,

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { calculateBoundsFromNodes } from './calculate_bounds_from_nodes';
import { SnapshotNode } from '../../../../../common/http_api/snapshot_api';
const nodes: SnapshotNode[] = [
{
path: [{ value: 'host-01', label: 'host-01' }],
metrics: [
{
name: 'cpu',
value: 0.5,
max: 1.5,
avg: 0.7,
},
],
},
{
path: [{ value: 'host-02', label: 'host-02' }],
metrics: [
{
name: 'cpu',
value: 0.2,
max: 0.7,
avg: 0.4,
},
],
},
];
describe('calculateBoundsFromNodes', () => {
it('should just work', () => {
const bounds = calculateBoundsFromNodes(nodes);
expect(bounds).toEqual({
min: 0.2,
max: 1.5,
});
});
it('should have a minimum of 0 for only a single node', () => {
const bounds = calculateBoundsFromNodes([nodes[0]]);
expect(bounds).toEqual({
min: 0,
max: 1.5,
});
});
it('should return zero for empty nodes', () => {
const bounds = calculateBoundsFromNodes([]);
expect(bounds).toEqual({
min: 0,
max: 0,
});
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { first, min, max, isFinite } from 'lodash';
import { SnapshotNode } from '../../../../../common/http_api/snapshot_api';
import { InfraWaffleMapBounds } from '../../../../lib/lib';
export const calculateBoundsFromNodes = (nodes: SnapshotNode[]): InfraWaffleMapBounds => {
const maxValues = nodes.map((node) => {
const metric = first(node.metrics);
if (!metric) return 0;
return metric.max;
});
const minValues = nodes.map((node) => {
const metric = first(node.metrics);
if (!metric) return 0;
return metric.value;
});
// if there is only one value then we need to set the bottom range to zero for min
// otherwise the legend will look silly since both values are the same for top and
// bottom.
if (minValues.length === 1) {
minValues.unshift(0);
}
const maxValue = max(maxValues) || 0;
const minValue = min(minValues) || 0;
return { min: isFinite(minValue) ? minValue : 0, max: isFinite(maxValue) ? maxValue : 0 };
};

View file

@ -36,12 +36,14 @@ describe('createUptimeLink()', () => {
name: 'host-01',
ip: '10.0.1.2',
path: [],
metric: {
name: 'cpu' as SnapshotMetricType,
value: 0.5,
max: 0.8,
avg: 0.6,
},
metrics: [
{
name: 'cpu' as SnapshotMetricType,
value: 0.5,
max: 0.8,
avg: 0.6,
},
],
};
expect(createUptimeLink(options, 'host', node)).toStrictEqual({
app: 'uptime',
@ -56,12 +58,14 @@ describe('createUptimeLink()', () => {
id: 'host-01',
name: 'host-01',
path: [],
metric: {
name: 'cpu' as SnapshotMetricType,
value: 0.5,
max: 0.8,
avg: 0.6,
},
metrics: [
{
name: 'cpu' as SnapshotMetricType,
value: 0.5,
max: 0.8,
avg: 0.6,
},
],
};
expect(createUptimeLink(options, 'host', node)).toStrictEqual({
app: 'uptime',
@ -76,12 +80,14 @@ describe('createUptimeLink()', () => {
id: '29193-pod-02939',
name: 'pod-01',
path: [],
metric: {
name: 'cpu' as SnapshotMetricType,
value: 0.5,
max: 0.8,
avg: 0.6,
},
metrics: [
{
name: 'cpu' as SnapshotMetricType,
value: 0.5,
max: 0.8,
avg: 0.6,
},
],
};
expect(createUptimeLink(options, 'pod', node)).toStrictEqual({
app: 'uptime',
@ -96,12 +102,14 @@ describe('createUptimeLink()', () => {
id: 'docker-1234',
name: 'docker-01',
path: [],
metric: {
name: 'cpu' as SnapshotMetricType,
value: 0.5,
max: 0.8,
avg: 0.6,
},
metrics: [
{
name: 'cpu' as SnapshotMetricType,
value: 0.5,
max: 0.8,
avg: 0.6,
},
],
};
expect(createUptimeLink(options, 'container', node)).toStrictEqual({
app: 'uptime',

View file

@ -95,7 +95,7 @@ export function createWaffleMapNode(node: SnapshotNode): InfraWaffleMapNode {
id: nodePathItem.value,
ip: nodePathItem.ip,
name: nodePathItem.label || nodePathItem.value,
metric: node.metric,
metrics: node.metrics,
};
}

View file

@ -0,0 +1,56 @@
/*
* 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 { sortNodes } from './sort_nodes';
import { SnapshotNode } from '../../../../../common/http_api/snapshot_api';
const nodes: SnapshotNode[] = [
{
path: [{ value: 'host-01', label: 'host-01' }],
metrics: [
{
name: 'cpu',
value: 0.5,
max: 1.5,
avg: 0.7,
},
],
},
{
path: [{ value: 'host-02', label: 'host-02' }],
metrics: [
{
name: 'cpu',
value: 0.2,
max: 0.7,
avg: 0.4,
},
],
},
];
describe('sortNodes', () => {
describe('asc', () => {
it('should sort by name', () => {
const sortedNodes = sortNodes({ by: 'name', direction: 'asc' }, nodes);
expect(sortedNodes).toEqual(nodes);
});
it('should sort by merics', () => {
const sortedNodes = sortNodes({ by: 'value', direction: 'asc' }, nodes);
expect(sortedNodes).toEqual(nodes.reverse());
});
});
describe('desc', () => {
it('should sort by name', () => {
const sortedNodes = sortNodes({ by: 'name', direction: 'desc' }, nodes);
expect(sortedNodes).toEqual(nodes.reverse());
});
it('should sort by merics', () => {
const sortedNodes = sortNodes({ by: 'value', direction: 'desc' }, nodes);
expect(sortedNodes).toEqual(nodes);
});
});
});

View file

@ -4,13 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { sortBy, last } from 'lodash';
import { sortBy, last, first } from 'lodash';
import { SnapshotNode } from '../../../../../common/http_api/snapshot_api';
import { WaffleSortOption } from '../hooks/use_waffle_options';
const SORT_PATHS = {
name: (node: SnapshotNode) => last(node.path),
value: (node: SnapshotNode) => node.metric.value || 0,
value: (node: SnapshotNode) => {
const metric = first(node.metrics);
return (metric && metric.value) || 0;
},
};
export const sortNodes = (sort: WaffleSortOption, nodes: SnapshotNode[]) => {

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 { mapValues, last } from 'lodash';
import { mapValues, last, first } from 'lodash';
import moment from 'moment';
import {
InfraDatabaseSearchResponse,
@ -95,7 +95,7 @@ const getData = async (
nodeType,
groupBy: [],
sourceConfiguration,
metric: { type: metric },
metrics: [{ type: metric }],
timerange,
includeTimeseries: Boolean(timerange.lookbackSize),
};
@ -104,12 +104,13 @@ const getData = async (
return nodes.reduce((acc, n) => {
const nodePathItem = last(n.path);
if (n.metric?.value && n.metric?.timeseries) {
const { timeseries } = n.metric;
const m = first(n.metrics);
if (m && m.value && m.timeseries) {
const { timeseries } = m;
const values = timeseries.rows.map((row) => row.metric_0) as Array<number | null>;
acc[nodePathItem.label] = values;
} else {
acc[nodePathItem.label] = n.metric && n.metric.value;
acc[nodePathItem.label] = m && m.value;
}
return acc;
}, {} as Record<string, number | Array<number | string | null | undefined> | undefined | null>);

View file

@ -54,14 +54,18 @@ export const getMetricsSources = (options: InfraSnapshotRequestOptions) => {
return [{ id: { terms: { field: fields.id } } }];
};
export const metricToAggregation = (nodeType: InventoryItemType, metric: SnapshotMetricInput) => {
export const metricToAggregation = (
nodeType: InventoryItemType,
metric: SnapshotMetricInput,
index: number
) => {
const inventoryModel = findInventoryModel(nodeType);
if (SnapshotCustomMetricInputRT.is(metric)) {
if (metric.aggregation === 'rate') {
return networkTraffic(metric.type, metric.field);
return networkTraffic(`custom_${index}`, metric.field);
}
return {
custom: {
[`custom_${index}`]: {
[metric.aggregation]: {
field: metric.field,
},
@ -72,19 +76,22 @@ export const metricToAggregation = (nodeType: InventoryItemType, metric: Snapsho
};
export const getMetricsAggregations = (options: InfraSnapshotRequestOptions): SnapshotModel => {
const aggregation = metricToAggregation(options.nodeType, options.metric);
if (!SnapshotModelRT.is(aggregation)) {
throw new Error(
i18n.translate('xpack.infra.snapshot.missingSnapshotMetricError', {
defaultMessage: 'The aggregation for {metric} for {nodeType} is not available.',
values: {
nodeType: options.nodeType,
metric: options.metric.type,
},
})
);
}
return aggregation;
const { metrics } = options;
return metrics.reduce((aggs, metric, index) => {
const aggregation = metricToAggregation(options.nodeType, metric, index);
if (!SnapshotModelRT.is(aggregation)) {
throw new Error(
i18n.translate('xpack.infra.snapshot.missingSnapshotMetricError', {
defaultMessage: 'The aggregation for {metric} for {nodeType} is not available.',
values: {
nodeType: options.nodeType,
metric: metric.type,
},
})
);
}
return { ...aggs, ...aggregation };
}, {});
};
export const getDateHistogramOffset = (from: number, interval: string): string => {

View file

@ -118,26 +118,28 @@ export const getNodeMetricsForLookup = (
export const getNodeMetrics = (
nodeBuckets: InfraSnapshotMetricsBucket[],
options: InfraSnapshotRequestOptions
): SnapshotNodeMetric => {
): SnapshotNodeMetric[] => {
if (!nodeBuckets) {
return {
name: options.metric.type,
return options.metrics.map((metric) => ({
name: metric.type,
value: null,
max: null,
avg: null,
};
}));
}
const lastBucket = findLastFullBucket(nodeBuckets, options);
const result: SnapshotNodeMetric = {
name: options.metric.type,
value: getMetricValueFromBucket(options.metric.type, lastBucket),
max: calculateMax(nodeBuckets, options.metric.type),
avg: calculateAvg(nodeBuckets, options.metric.type),
};
if (options.includeTimeseries) {
result.timeseries = getTimeseriesData(nodeBuckets, options.metric.type);
}
return result;
return options.metrics.map((metric, index) => {
const metricResult: SnapshotNodeMetric = {
name: metric.type,
value: getMetricValueFromBucket(metric.type, lastBucket, index),
max: calculateMax(nodeBuckets, metric.type, index),
avg: calculateAvg(nodeBuckets, metric.type, index),
};
if (options.includeTimeseries) {
metricResult.timeseries = getTimeseriesData(nodeBuckets, metric.type, index);
}
return metricResult;
});
};
const findLastFullBucket = (
@ -156,22 +158,39 @@ const findLastFullBucket = (
}, last(buckets));
};
const getMetricValueFromBucket = (type: SnapshotMetricType, bucket: InfraSnapshotMetricsBucket) => {
const metric = bucket[type];
const getMetricValueFromBucket = (
type: SnapshotMetricType,
bucket: InfraSnapshotMetricsBucket,
index: number
) => {
const key = type === 'custom' ? `custom_${index}` : type;
const metric = bucket[key];
return (metric && (metric.normalized_value || metric.value)) || 0;
};
function calculateMax(buckets: InfraSnapshotMetricsBucket[], type: SnapshotMetricType) {
return max(buckets.map((bucket) => getMetricValueFromBucket(type, bucket))) || 0;
function calculateMax(
buckets: InfraSnapshotMetricsBucket[],
type: SnapshotMetricType,
index: number
) {
return max(buckets.map((bucket) => getMetricValueFromBucket(type, bucket, index))) || 0;
}
function calculateAvg(buckets: InfraSnapshotMetricsBucket[], type: SnapshotMetricType) {
return sum(buckets.map((bucket) => getMetricValueFromBucket(type, bucket))) / buckets.length || 0;
function calculateAvg(
buckets: InfraSnapshotMetricsBucket[],
type: SnapshotMetricType,
index: number
) {
return (
sum(buckets.map((bucket) => getMetricValueFromBucket(type, bucket, index))) / buckets.length ||
0
);
}
function getTimeseriesData(
buckets: InfraSnapshotMetricsBucket[],
type: SnapshotMetricType
type: SnapshotMetricType,
index: number
): MetricsExplorerSeries {
return {
id: type,
@ -181,7 +200,7 @@ function getTimeseriesData(
],
rows: buckets.map((bucket) => ({
timestamp: bucket.key as number,
metric_0: getMetricValueFromBucket(type, bucket),
metric_0: getMetricValueFromBucket(type, bucket, index),
})),
};
}

View file

@ -110,15 +110,22 @@ const requestGroupedNodes = async (
>(callClusterFactory(client), query, bucketSelector, handleAfterKey);
};
const calculateIndexPatterBasedOnMetrics = (options: InfraSnapshotRequestOptions) => {
const { metrics } = options;
if (metrics.every((m) => m.type === 'logRate')) {
return options.sourceConfiguration.logAlias;
}
if (metrics.some((m) => m.type === 'logRate')) {
return `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`;
}
return options.sourceConfiguration.metricAlias;
};
const requestNodeMetrics = async (
client: ESSearchClient,
options: InfraSnapshotRequestOptions
): Promise<InfraSnapshotNodeMetricsBucket[]> => {
const index =
options.metric.type === 'logRate'
? `${options.sourceConfiguration.logAlias}`
: `${options.sourceConfiguration.metricAlias}`;
const index = calculateIndexPatterBasedOnMetrics(options);
const query = {
allowNoIndices: true,
index,
@ -179,7 +186,7 @@ const mergeNodeBuckets = (
return nodeGroupByBuckets.map((node) => {
return {
path: getNodePath(node, options),
metric: getNodeMetrics(nodeMetricsForLookup[node.key.id], options),
metrics: getNodeMetrics(nodeMetricsForLookup[node.key.id], options),
};
});
};

View file

@ -35,7 +35,7 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => {
nodeType,
groupBy,
sourceId,
metric,
metrics,
timerange,
accountId,
region,
@ -56,7 +56,7 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => {
nodeType,
groupBy,
sourceConfiguration: source.configuration,
metric,
metrics,
timerange,
includeTimeseries,
};

View file

@ -47,7 +47,7 @@ export default function ({ getService }: FtrProviderContext) {
from: min,
interval: '1m',
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'container' as InfraNodeType,
groupBy: [],
});
@ -68,13 +68,15 @@ export default function ({ getService }: FtrProviderContext) {
'242fddb9d376bbf0e38025d81764847ee5ec0308adfa095918fd3266f9d06c6a'
);
expect(first(firstNode.path)).to.have.property('label', 'docker-autodiscovery_nginx_1');
expect(firstNode).to.have.property('metric');
expect(firstNode.metric).to.eql({
name: 'cpu',
value: 0,
max: 0,
avg: 0,
});
expect(firstNode).to.have.property('metrics');
expect(firstNode.metrics).to.eql([
{
name: 'cpu',
value: 0,
max: 0,
avg: 0,
},
]);
}
});
});
@ -93,7 +95,7 @@ export default function ({ getService }: FtrProviderContext) {
from: min,
interval: '1m',
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'pod' as InfraNodeType,
groupBy: [],
});
@ -125,7 +127,7 @@ export default function ({ getService }: FtrProviderContext) {
from: min,
interval: '1m',
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'container' as InfraNodeType,
groupBy: [],
});
@ -164,7 +166,7 @@ export default function ({ getService }: FtrProviderContext) {
from: min,
interval: '1m',
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'host' as InfraNodeType,
groupBy: [],
});
@ -179,13 +181,15 @@ export default function ({ getService }: FtrProviderContext) {
expect(firstNode.path.length).to.equal(1);
expect(first(firstNode.path)).to.have.property('value', 'demo-stack-mysql-01');
expect(first(firstNode.path)).to.have.property('label', 'demo-stack-mysql-01');
expect(firstNode).to.have.property('metric');
expect(firstNode.metric).to.eql({
name: 'cpu',
value: 0.0032,
max: 0.0038333333333333336,
avg: 0.0027944444444444444,
});
expect(firstNode).to.have.property('metrics');
expect(firstNode.metrics).to.eql([
{
name: 'cpu',
value: 0.0032,
max: 0.0038333333333333336,
avg: 0.0027944444444444444,
},
]);
}
});
});
@ -200,7 +204,7 @@ export default function ({ getService }: FtrProviderContext) {
forceInterval: true,
ignoreLookback: true,
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'host' as InfraNodeType,
groupBy: [],
includeTimeseries: true,
@ -216,10 +220,10 @@ export default function ({ getService }: FtrProviderContext) {
expect(firstNode.path.length).to.equal(1);
expect(first(firstNode.path)).to.have.property('value', 'demo-stack-mysql-01');
expect(first(firstNode.path)).to.have.property('label', 'demo-stack-mysql-01');
expect(firstNode).to.have.property('metric');
expect(firstNode.metric).to.have.property('timeseries');
expect(firstNode.metric.timeseries?.rows.length).to.equal(58);
const rows = firstNode.metric.timeseries?.rows;
expect(firstNode).to.have.property('metrics');
expect(firstNode.metrics[0]).to.have.property('timeseries');
expect(firstNode.metrics[0].timeseries?.rows.length).to.equal(58);
const rows = firstNode.metrics[0].timeseries?.rows;
const rowInterval = (rows?.[1]?.timestamp || 0) - (rows?.[0]?.timestamp || 0);
expect(rowInterval).to.equal(10000);
}
@ -235,7 +239,7 @@ export default function ({ getService }: FtrProviderContext) {
interval: '1m',
lookbackSize: 6,
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'host' as InfraNodeType,
groupBy: [],
includeTimeseries: true,
@ -251,9 +255,9 @@ export default function ({ getService }: FtrProviderContext) {
expect(firstNode.path.length).to.equal(1);
expect(first(firstNode.path)).to.have.property('value', 'demo-stack-mysql-01');
expect(first(firstNode.path)).to.have.property('label', 'demo-stack-mysql-01');
expect(firstNode).to.have.property('metric');
expect(firstNode.metric).to.have.property('timeseries');
expect(firstNode.metric.timeseries?.rows.length).to.equal(7);
expect(firstNode).to.have.property('metrics');
expect(firstNode.metrics[0]).to.have.property('timeseries');
expect(firstNode.metrics[0].timeseries?.rows.length).to.equal(7);
}
});
});
@ -266,12 +270,14 @@ export default function ({ getService }: FtrProviderContext) {
from: min,
interval: '1m',
},
metric: {
type: 'custom',
field: 'system.cpu.user.pct',
aggregation: 'avg',
id: '1',
} as SnapshotMetricInput,
metrics: [
{
type: 'custom',
field: 'system.cpu.user.pct',
aggregation: 'avg',
id: '1',
},
] as SnapshotMetricInput[],
nodeType: 'host' as InfraNodeType,
groupBy: [],
});
@ -286,13 +292,15 @@ export default function ({ getService }: FtrProviderContext) {
expect(firstNode.path.length).to.equal(1);
expect(first(firstNode.path)).to.have.property('value', 'demo-stack-mysql-01');
expect(first(firstNode.path)).to.have.property('label', 'demo-stack-mysql-01');
expect(firstNode).to.have.property('metric');
expect(firstNode.metric).to.eql({
name: 'custom',
value: 0.0016,
max: 0.0018333333333333333,
avg: 0.0013666666666666669,
});
expect(firstNode).to.have.property('metrics');
expect(firstNode.metrics).to.eql([
{
name: 'custom',
value: 0.0016,
max: 0.0018333333333333333,
avg: 0.0013666666666666669,
},
]);
}
});
@ -304,7 +312,7 @@ export default function ({ getService }: FtrProviderContext) {
from: min,
interval: '1m',
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'host' as InfraNodeType,
groupBy: [{ field: 'cloud.availability_zone' }],
});
@ -331,7 +339,7 @@ export default function ({ getService }: FtrProviderContext) {
from: min,
interval: '1m',
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'host' as InfraNodeType,
groupBy: [{ field: 'cloud.provider' }, { field: 'cloud.availability_zone' }],
});
@ -360,7 +368,7 @@ export default function ({ getService }: FtrProviderContext) {
from: min,
interval: '1m',
},
metric: { type: 'cpu' } as InfraSnapshotMetricInput,
metrics: [{ type: 'cpu' }] as InfraSnapshotMetricInput[],
nodeType: 'host' as InfraNodeType,
groupBy: [{ field: 'service.type' }],
});
@ -375,25 +383,29 @@ export default function ({ getService }: FtrProviderContext) {
expect(firstNode.path.length).to.equal(2);
expect(firstNode.path[0]).to.have.property('value', 'mysql');
expect(firstNode.path[1]).to.have.property('value', 'demo-stack-mysql-01');
expect(firstNode).to.have.property('metric');
expect(firstNode.metric).to.eql({
name: 'cpu',
value: 0.0032,
max: 0.0038333333333333336,
avg: 0.0027944444444444444,
});
expect(firstNode).to.have.property('metrics');
expect(firstNode.metrics).to.eql([
{
name: 'cpu',
value: 0.0032,
max: 0.0038333333333333336,
avg: 0.0027944444444444444,
},
]);
const secondNode = nodes[1];
expect(secondNode).to.have.property('path');
expect(secondNode.path.length).to.equal(2);
expect(secondNode.path[0]).to.have.property('value', 'system');
expect(secondNode.path[1]).to.have.property('value', 'demo-stack-mysql-01');
expect(secondNode).to.have.property('metric');
expect(secondNode.metric).to.eql({
name: 'cpu',
value: 0.0032,
max: 0.0038333333333333336,
avg: 0.0027944444444444444,
});
expect(secondNode).to.have.property('metrics');
expect(secondNode.metrics).to.eql([
{
name: 'cpu',
value: 0.0032,
max: 0.0038333333333333336,
avg: 0.0027944444444444444,
},
]);
}
});
});