From fbd76d3443c9cbdd465ef8799ba20fc24a455127 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Tue, 19 May 2020 16:55:18 -0700 Subject: [PATCH] [Metrics UI] Add support for multiple groupings to Metrics Explorer (and Alerts) (#66503) (#67065) * [Metrics UI] Adding support for multiple groupings to Metrics Explorer * Adding keys to title parts * removing commented line Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- .../infra/common/http_api/metrics_explorer.ts | 24 +++++--- .../components/expression.tsx | 7 ++- .../public/alerting/metric_threshold/types.ts | 2 +- .../metrics_explorer/components/chart.tsx | 10 ++-- .../components/chart_context_menu.tsx | 19 ++++-- .../components/chart_title.tsx | 40 +++++++++++++ .../metrics_explorer/components/charts.tsx | 10 +++- .../metrics_explorer/components/group_by.tsx | 16 +++-- .../components/helpers/create_tsvb_link.ts | 16 ++++- .../metrics_explorer/components/toolbar.tsx | 2 +- .../hooks/use_metric_explorer_state.ts | 4 +- .../hooks/use_metrics_explorer_data.test.tsx | 2 +- .../hooks/use_metrics_explorer_data.ts | 2 +- .../hooks/use_metrics_explorer_options.ts | 2 +- .../metric_threshold_executor.ts | 34 +++++++---- .../register_metric_threshold_alert_type.ts | 2 +- .../alerting/metric_threshold/test_mocks.ts | 12 ++-- .../metrics_explorer/lib/get_groupings.ts | 60 +++++++++++++------ .../lib/populate_series_with_tsvb_data.ts | 18 +++++- .../apis/infra/metrics_explorer.ts | 59 ++++++++++++++---- 20 files changed, 256 insertions(+), 85 deletions(-) create mode 100644 x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts index d3a978c9963cf..0f63b8d275e65 100644 --- a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts @@ -52,9 +52,12 @@ export const metricsExplorerRequestBodyRequiredFieldsRT = rt.type({ metrics: rt.array(metricsExplorerMetricRT), }); +const groupByRT = rt.union([rt.string, rt.null, rt.undefined]); +export const afterKeyObjectRT = rt.record(rt.string, rt.union([rt.string, rt.null])); + export const metricsExplorerRequestBodyOptionalFieldsRT = rt.partial({ - groupBy: rt.union([rt.string, rt.null, rt.undefined]), - afterKey: rt.union([rt.string, rt.null, rt.undefined]), + groupBy: rt.union([groupByRT, rt.array(groupByRT)]), + afterKey: rt.union([rt.string, rt.null, rt.undefined, afterKeyObjectRT]), limit: rt.union([rt.number, rt.null, rt.undefined]), filterQuery: rt.union([rt.string, rt.null, rt.undefined]), forceInterval: rt.boolean, @@ -68,7 +71,7 @@ export const metricsExplorerRequestBodyRT = rt.intersection([ export const metricsExplorerPageInfoRT = rt.type({ total: rt.number, - afterKey: rt.union([rt.string, rt.null]), + afterKey: rt.union([rt.string, rt.null, afterKeyObjectRT]), }); export const metricsExplorerColumnTypeRT = rt.keyof({ @@ -89,11 +92,16 @@ export const metricsExplorerRowRT = rt.intersection([ rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])), ]); -export const metricsExplorerSeriesRT = rt.type({ - id: rt.string, - columns: rt.array(metricsExplorerColumnRT), - rows: rt.array(metricsExplorerRowRT), -}); +export const metricsExplorerSeriesRT = rt.intersection([ + rt.type({ + id: rt.string, + columns: rt.array(metricsExplorerColumnRT), + rows: rt.array(metricsExplorerRowRT), + }), + rt.partial({ + keys: rt.array(rt.string), + }), +]); export const metricsExplorerResponseRT = rt.type({ series: rt.array(metricsExplorerSeriesRT), diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index a176ba756652a..4151fd8d6cf49 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -138,7 +138,7 @@ export const Expressions: React.FC = props => { ]); const onGroupByChange = useCallback( - (group: string | null) => { + (group: string | null | string[]) => { setAlertParams('groupBy', group || ''); }, [setAlertParams] @@ -206,7 +206,10 @@ export const Expressions: React.FC = props => { convertKueryToElasticSearchQuery(md.currentOptions.filterQuery, derivedIndexPattern) || '' ); } else if (md && md.currentOptions?.groupBy && md.series) { - const filter = `${md.currentOptions?.groupBy}: "${md.series.id}"`; + const { groupBy } = md.currentOptions; + const filter = Array.isArray(groupBy) + ? groupBy.map((field, index) => `${field}: "${md.series?.keys?.[index]}"`).join(' and ') + : `${groupBy}: "${md.series.id}"`; setAlertParams('filterQueryText', filter); setAlertParams( 'filterQuery', diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts index e421455cf6efd..feeec4b0ce8bf 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts @@ -35,7 +35,7 @@ export enum AGGREGATION_TYPES { export interface MetricThresholdAlertParams { criteria?: MetricExpression[]; - groupBy?: string; + groupBy?: string | string[]; filterQuery?: string; sourceId?: string; } diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx index 85151f822015a..e9e2ca4d9682a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx @@ -35,6 +35,7 @@ import { getChartTheme } from './helpers/get_chart_theme'; import { useKibanaUiSetting } from '../../../../utils/use_kibana_ui_setting'; import { calculateDomain } from './helpers/calculate_domain'; import { useKibana, useUiSetting } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ChartTitle } from './chart_title'; interface Props { title?: string | null; @@ -91,16 +92,17 @@ export const MetricsExplorerChart = ({ chartOptions.yAxisMode === MetricsExplorerYAxisMode.fromZero ? { ...dataDomain, min: 0 } : dataDomain; + return (
{options.groupBy ? ( - + - {title} + - + { - if (source.fields.host === field) { + const fields = Array.isArray(groupBy) ? groupBy : [groupBy]; + if (fields.includes(source.fields.host)) { return 'host'; } - if (source.fields.pod === field) { + if (fields.includes(source.fields.pod)) { return 'pod'; } - if (source.fields.container === field) { + if (fields.includes(source.fields.container)) { return 'container'; } }; @@ -88,10 +89,16 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ // onFilter needs check for Typescript even though it's // covered by supportFiltering variable if (supportFiltering && onFilter) { - onFilter(`${options.groupBy}: "${series.id}"`); + if (Array.isArray(options.groupBy)) { + onFilter( + options.groupBy.map((field, index) => `${field}: "${series.keys?.[index]}"`).join(' and ') + ); + } else { + onFilter(`${options.groupBy}: "${series.id}"`); + } } setPopoverState(false); - }, [supportFiltering, options.groupBy, series.id, onFilter]); + }, [supportFiltering, onFilter, options, series.keys, series.id]); // Only display the "Add Filter" option if it's supported const filterByItem = supportFiltering diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx new file mode 100644 index 0000000000000..e756c3bc393ce --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx @@ -0,0 +1,40 @@ +/* + * 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, { Fragment } from 'react'; +import { EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { MetricsExplorerSeries } from '../../../../../common/http_api'; + +interface Props { + series: MetricsExplorerSeries; +} + +export const ChartTitle = ({ series }: Props) => { + if (series.keys != null) { + const { keys } = series; + return ( + + {keys.map((name, i) => ( + + + i ? 'subdued' : 'default'}> + {name} + + + {keys.length - 1 > i && ( + + + / + + + )} + + ))} + + ); + } + return {series.id}; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx index 1e216a4227f9a..66d478ad2ff03 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx @@ -19,11 +19,13 @@ import { NoData } from '../../../../components/empty_states/no_data'; import { MetricsExplorerChart } from './chart'; import { SourceQuery } from '../../../../graphql/types'; +type stringOrNull = string | null; + interface Props { loading: boolean; options: MetricsExplorerOptions; chartOptions: MetricsExplorerChartOptions; - onLoadMore: (afterKey: string | null) => void; + onLoadMore: (afterKey: stringOrNull | Record) => void; onRefetch: () => void; onFilter: (filter: string) => void; onTimeChange: (start: string, end: string) => void; @@ -73,6 +75,8 @@ export const MetricsExplorerCharts = ({ ); } + const and = i18n.translate('xpack.infra.metricsExplorer.andLabel', { defaultMessage: '" and "' }); + return (
@@ -104,7 +108,9 @@ export const MetricsExplorerCharts = ({ values={{ length: data.series.length, total: data.pageInfo.total, - groupBy: options.groupBy, + groupBy: Array.isArray(options.groupBy) + ? options.groupBy.join(and) + : options.groupBy, }} />

diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/group_by.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/group_by.tsx index bfe8ddb2e0829..793ddd3ce5691 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/group_by.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/group_by.tsx @@ -13,19 +13,25 @@ import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options'; interface Props { options: MetricsExplorerOptions; - onChange: (groupBy: string | null) => void; + onChange: (groupBy: string | null | string[]) => void; fields: IFieldType[]; } export const MetricsExplorerGroupBy = ({ options, onChange, fields }: Props) => { const handleChange = useCallback( - selectedOptions => { - const groupBy = (selectedOptions.length === 1 && selectedOptions[0].label) || null; + (selectedOptions: Array<{ label: string }>) => { + const groupBy = selectedOptions.map(option => option.label); onChange(groupBy); }, [onChange] ); + const selectedOptions = Array.isArray(options.groupBy) + ? options.groupBy.map(field => ({ label: field })) + : options.groupBy + ? [{ label: options.groupBy }] + : []; + return ( defaultMessage: 'Graph per', })} fullWidth - singleSelection={true} - selectedOptions={(options.groupBy && [{ label: options.groupBy }]) || []} + singleSelection={false} + selectedOptions={selectedOptions} options={fields .filter(f => f.aggregatable && f.type === 'string') .map(f => ({ label: f.name }))} diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts index 19ea4cc7f7dec..a81e11418cd6a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts @@ -109,7 +109,21 @@ export const createFilterFromOptions = ( } if (options.groupBy) { const id = series.id.replace('"', '\\"'); - filters.push(`${options.groupBy} : "${id}"`); + const groupByFilters = Array.isArray(options.groupBy) + ? options.groupBy + .map((field, index) => { + if (!series.keys) { + return null; + } + const value = series.keys[index]; + if (!value) { + return null; + } + return `${field}: "${value.replace('"', '\\"')}"`; + }) + .join(' and ') + : `${options.groupBy} : "${id}"`; + filters.push(groupByFilters); } return { language: 'kuery', query: filters.join(' and ') }; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx index 76945eb528345..7ad1d943a9896 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx @@ -37,7 +37,7 @@ interface Props { defaultViewState: MetricExplorerViewState; onRefresh: () => void; onTimeChange: (start: string, end: string) => void; - onGroupByChange: (groupBy: string | null) => void; + onGroupByChange: (groupBy: string | null | string[]) => void; onFilterQuerySubmit: (query: string) => void; onMetricsChange: (metrics: MetricsExplorerMetric[]) => void; onAggregationChange: (aggregation: MetricsExplorerAggregation) => void; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts index 8a9ed901de0b0..936c6e456beb7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts @@ -30,7 +30,7 @@ export const useMetricsExplorerState = ( derivedIndexPattern: IIndexPattern ) => { const [refreshSignal, setRefreshSignal] = useState(0); - const [afterKey, setAfterKey] = useState(null); + const [afterKey, setAfterKey] = useState>(null); const { defaultViewState, options, @@ -63,7 +63,7 @@ export const useMetricsExplorerState = ( ); const handleGroupByChange = useCallback( - (groupBy: string | null) => { + (groupBy: string | null | string[]) => { setAfterKey(null); setOptions({ ...options, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx index 94edab54fb71e..f0b2627288d45 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx @@ -46,7 +46,7 @@ const renderUseMetricsExplorerDataHook = () => { source, derivedIndexPattern, timeRange, - afterKey: null as string | null, + afterKey: null as string | null | Record, signal: 1, }, wrapper, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts index 6b4ac8b1ba060..3a767b94d00c7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts @@ -28,7 +28,7 @@ export function useMetricsExplorerData( source: SourceQuery.Query['source']['configuration'] | undefined, derivedIndexPattern: IIndexPattern, timerange: MetricsExplorerTimeOptions, - afterKey: string | null, + afterKey: string | null | Record, signal: any, fetch?: HttpHandler ) { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index f79c7aa0d4d67..56595c09aadde 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -37,7 +37,7 @@ export interface MetricsExplorerChartOptions { export interface MetricsExplorerOptions { metrics: MetricsExplorerOptionsMetric[]; limit?: number; - groupBy?: string; + groupBy?: string | string[]; filterQuery?: string; aggregation: MetricsExplorerAggregation; forceInterval?: boolean; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 71bee3209bf53..9738acd13eb6e 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -74,7 +74,7 @@ const getParsedFilterQuery: ( export const getElasticsearchMetricQuery = ( { metric, aggType, timeUnit, timeSize }: MetricExpressionParams, timefield: string, - groupBy?: string, + groupBy?: string | string[], filterQuery?: string ) => { if (aggType === Aggregators.COUNT && metric) { @@ -126,15 +126,21 @@ export const getElasticsearchMetricQuery = ( groupings: { composite: { size: 10, - sources: [ - { - groupBy: { - terms: { - field: groupBy, + sources: Array.isArray(groupBy) + ? groupBy.map((field, index) => ({ + [`groupBy${index}`]: { + terms: { field }, }, - }, - }, - ], + })) + : [ + { + groupBy0: { + terms: { + field: groupBy, + }, + }, + }, + ], }, aggs: baseAggs, }, @@ -186,7 +192,7 @@ const getMetric: ( params: MetricExpressionParams, index: string, timefield: string, - groupBy: string | undefined, + groupBy: string | undefined | string[], filterQuery: string | undefined ) => Promise> = async function( { callCluster }, @@ -213,11 +219,13 @@ const getMetric: ( searchBody, bucketSelector, afterKeyHandler - )) as Array; + )) as Array }>; return compositeBuckets.reduce( (result, bucket) => ({ ...result, - [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket, aggType), + [Object.values(bucket.key) + .map(value => value) + .join(', ')]: getCurrentValueFromAggregations(bucket, aggType), }), {} ); @@ -249,7 +257,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: s async function({ services, params }: AlertExecutorOptions) { const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as { criteria: MetricExpressionParams[]; - groupBy: string | undefined; + groupBy: string | undefined | string[]; filterQuery: string | undefined; sourceId?: string; alertOnNoData: boolean; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index c1c530131f3a0..8b3903f2ee3be 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -62,7 +62,7 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { params: schema.object( { criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])), - groupBy: schema.maybe(schema.string()), + groupBy: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), filterQuery: schema.maybe( schema.string({ validate: validateIsStringElasticsearchJSONFilter, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts index 25b709d6afc51..ee2cf94a2fd62 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -64,11 +64,11 @@ export const emptyMetricResponse = { export const basicCompositeResponse = { aggregations: { groupings: { - after_key: 'foo', + after_key: { groupBy0: 'foo' }, buckets: [ { key: { - groupBy: 'a', + groupBy0: 'a', }, aggregatedIntervals: { buckets: bucketsA, @@ -76,7 +76,7 @@ export const basicCompositeResponse = { }, { key: { - groupBy: 'b', + groupBy0: 'b', }, aggregatedIntervals: { buckets: bucketsB, @@ -95,11 +95,11 @@ export const basicCompositeResponse = { export const alternateCompositeResponse = { aggregations: { groupings: { - after_key: 'foo', + after_key: { groupBy0: 'foo' }, buckets: [ { key: { - groupBy: 'a', + groupBy0: 'a', }, aggregatedIntervals: { buckets: bucketsB, @@ -107,7 +107,7 @@ export const alternateCompositeResponse = { }, { key: { - groupBy: 'b', + groupBy0: 'b', }, aggregatedIntervals: { buckets: bucketsA, diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_groupings.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_groupings.ts index 47dc58997a469..a6510b2ba1478 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_groupings.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_groupings.ts @@ -5,10 +5,12 @@ */ import { isObject, set } from 'lodash'; +import { i18n } from '@kbn/i18n'; import { InfraDatabaseSearchResponse } from '../../../lib/adapters/framework'; import { MetricsExplorerRequestBody, MetricsExplorerResponse, + afterKeyObjectRT, } from '../../../../common/http_api/metrics_explorer'; interface GroupingAggregation { @@ -24,7 +26,13 @@ interface GroupingAggregation { } const EMPTY_RESPONSE = { - series: [{ id: 'ALL', columns: [], rows: [] }], + series: [ + { + id: i18n.translate('xpack.infra.metricsExploer.everything', { defaultMessage: 'Everything' }), + columns: [], + rows: [], + }, + ], pageInfo: { total: 0, afterKey: null }, }; @@ -35,7 +43,25 @@ export const getGroupings = async ( if (!options.groupBy) { return EMPTY_RESPONSE; } + + if (Array.isArray(options.groupBy) && options.groupBy.length === 0) { + return EMPTY_RESPONSE; + } + const limit = options.limit || 9; + const groupBy = Array.isArray(options.groupBy) ? options.groupBy : [options.groupBy]; + const filter: Array> = [ + { + range: { + [options.timerange.field]: { + gte: options.timerange.from, + lte: options.timerange.to, + format: 'epoch_millis', + }, + }, + }, + ...groupBy.map(field => ({ exists: { field } })), + ]; const params = { allowNoIndices: true, ignoreUnavailable: true, @@ -51,27 +77,21 @@ export const getGroupings = async ( exists: { field: m.field }, })), ], - filter: [ - { - range: { - [options.timerange.field]: { - gte: options.timerange.from, - lte: options.timerange.to, - format: 'epoch_millis', - }, - }, - }, - ] as object[], + filter, }, }, aggs: { groupingsCount: { - cardinality: { field: options.groupBy }, + cardinality: { + script: { source: groupBy.map(field => `doc['${field}'].value`).join('+') }, + }, }, groupings: { composite: { size: limit, - sources: [{ groupBy: { terms: { field: options.groupBy, order: 'asc' } } }], + sources: groupBy.map((field, index) => ({ + [`groupBy${index}`]: { terms: { field, order: 'asc' } }, + })), }, }, }, @@ -83,7 +103,11 @@ export const getGroupings = async ( } if (options.afterKey) { - set(params, 'body.aggs.groupings.composite.after', { groupBy: options.afterKey }); + if (afterKeyObjectRT.is(options.afterKey)) { + set(params, 'body.aggs.groupings.composite.after', options.afterKey); + } else { + set(params, 'body.aggs.groupings.composite.after', { groupBy0: options.afterKey }); + } } if (options.filterQuery) { @@ -113,11 +137,13 @@ export const getGroupings = async ( const { after_key: afterKey } = groupings; return { series: groupings.buckets.map(bucket => { - return { id: bucket.key.groupBy, rows: [], columns: [] }; + const keys = Object.values(bucket.key); + const id = keys.join(' / '); + return { id, keys, rows: [], columns: [] }; }), pageInfo: { total: groupingsCount.value, - afterKey: afterKey && groupings.buckets.length === limit ? afterKey.groupBy : null, + afterKey: afterKey && groupings.buckets.length === limit ? afterKey : null, }, }; }; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts index a709cbdeeb680..ea77050112e19 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { union, uniq } from 'lodash'; +import { union, uniq, isArray, isString } from 'lodash'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter'; import { @@ -38,9 +38,21 @@ export const populateSeriesWithTSVBData = ( } // Set the filter for the group by or match everything - const filters: JsonObject[] = options.groupBy - ? [{ match: { [options.groupBy]: series.id } }] + const isGroupBySet = + Array.isArray(options.groupBy) && options.groupBy.length + ? true + : isString(options.groupBy) + ? true + : false; + + const filters: JsonObject[] = isGroupBySet + ? isArray(options.groupBy) + ? options.groupBy + .filter(f => f) + .map((field, index) => ({ match: { [field as string]: series.keys?.[index] || '' } })) + : [{ match: { [options.groupBy as string]: series.id } }] : []; + if (options.filterQuery) { try { const filterQuery = JSON.parse(options.filterQuery); diff --git a/x-pack/test/api_integration/apis/infra/metrics_explorer.ts b/x-pack/test/api_integration/apis/infra/metrics_explorer.ts index 1563ae208867c..4491944699b21 100644 --- a/x-pack/test/api_integration/apis/infra/metrics_explorer.ts +++ b/x-pack/test/api_integration/apis/infra/metrics_explorer.ts @@ -36,11 +36,9 @@ export default function({ getService }: FtrProviderContext) { { aggregation: 'avg', field: 'system.cpu.user.pct', - rate: false, }, { aggregation: 'count', - rate: false, }, ], }; @@ -52,7 +50,7 @@ export default function({ getService }: FtrProviderContext) { const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); expect(body.series).length(1); const firstSeries = first(body.series); - expect(firstSeries).to.have.property('id', 'ALL'); + expect(firstSeries).to.have.property('id', 'Everything'); expect(firstSeries.columns).to.eql([ { name: 'timestamp', type: 'date' }, { name: 'metric_0', type: 'number' }, @@ -81,7 +79,6 @@ export default function({ getService }: FtrProviderContext) { { aggregation: 'avg', field: 'system.cpu.user.pct', - rate: false, }, ], }; @@ -93,7 +90,7 @@ export default function({ getService }: FtrProviderContext) { const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); expect(body.series).length(1); const firstSeries = first(body.series); - expect(firstSeries).to.have.property('id', 'ALL'); + expect(firstSeries).to.have.property('id', 'Everything'); expect(firstSeries.columns).to.eql([ { name: 'timestamp', type: 'date' }, { name: 'metric_0', type: 'number' }, @@ -124,7 +121,7 @@ export default function({ getService }: FtrProviderContext) { const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); expect(body.series).length(1); const firstSeries = first(body.series); - expect(firstSeries).to.have.property('id', 'ALL'); + expect(firstSeries).to.have.property('id', 'Everything'); expect(firstSeries.columns).to.eql([]); expect(firstSeries.rows).to.have.length(0); }); @@ -144,7 +141,6 @@ export default function({ getService }: FtrProviderContext) { metrics: [ { aggregation: 'count', - rate: false, }, ], }; @@ -169,10 +165,55 @@ export default function({ getService }: FtrProviderContext) { timestamp: 1547571300000, }); expect(body.pageInfo).to.eql({ - afterKey: 'system.fsstat', + afterKey: { groupBy0: 'system.fsstat' }, total: 12, }); }); + + it('should work with multiple groupBy', async () => { + const postBody = { + timerange: { + field: '@timestamp', + to: max, + from: min, + interval: '>=1m', + }, + indexPattern: 'metricbeat-*', + groupBy: ['host.name', 'system.network.name'], + limit: 3, + afterKey: null, + metrics: [ + { + aggregation: 'rate', + field: 'system.network.out.bytes', + }, + ], + }; + const response = await supertest + .post('/api/infra/metrics_explorer') + .set('kbn-xsrf', 'xxx') + .send(postBody) + .expect(200); + const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); + expect(body.series).length(3); + const firstSeries = first(body.series); + expect(firstSeries).to.have.property('id', 'demo-stack-mysql-01 / eth0'); + expect(firstSeries.columns).to.eql([ + { name: 'timestamp', type: 'date' }, + { name: 'metric_0', type: 'number' }, + { name: 'groupBy', type: 'string' }, + ]); + expect(firstSeries.rows).to.have.length(9); + expect(firstSeries.rows![1]).to.eql({ + groupBy: 'demo-stack-mysql-01 / eth0', + metric_0: 53577.683333333334, + timestamp: 1547571300000, + }); + expect(body.pageInfo).to.eql({ + afterKey: { groupBy0: 'demo-stack-mysql-01', groupBy1: 'eth2' }, + total: 4, + }); + }); }); describe('without data', () => { @@ -191,7 +232,6 @@ export default function({ getService }: FtrProviderContext) { { aggregation: 'avg', field: 'system.cpu.user.pct', - rate: false, }, ], }; @@ -225,7 +265,6 @@ export default function({ getService }: FtrProviderContext) { { aggregation: 'avg', field: 'system.cpu.user.pct', - rate: false, }, ], };