[Metrics UI] Add support for multiple groupings to Metrics Explorer (and Alerts) (#66503)

* [Metrics UI] Adding support for multiple groupings to Metrics Explorer

* Adding keys to title parts

* removing commented line

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Chris Cowan 2020-05-19 14:42:49 -07:00 committed by GitHub
parent a7c2db73c5
commit 59ae529322
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 256 additions and 85 deletions

View file

@ -52,9 +52,12 @@ export const metricsExplorerRequestBodyRequiredFieldsRT = rt.type({
metrics: rt.array(metricsExplorerMetricRT), 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({ export const metricsExplorerRequestBodyOptionalFieldsRT = rt.partial({
groupBy: rt.union([rt.string, rt.null, rt.undefined]), groupBy: rt.union([groupByRT, rt.array(groupByRT)]),
afterKey: rt.union([rt.string, rt.null, rt.undefined]), afterKey: rt.union([rt.string, rt.null, rt.undefined, afterKeyObjectRT]),
limit: rt.union([rt.number, rt.null, rt.undefined]), limit: rt.union([rt.number, rt.null, rt.undefined]),
filterQuery: rt.union([rt.string, rt.null, rt.undefined]), filterQuery: rt.union([rt.string, rt.null, rt.undefined]),
forceInterval: rt.boolean, forceInterval: rt.boolean,
@ -68,7 +71,7 @@ export const metricsExplorerRequestBodyRT = rt.intersection([
export const metricsExplorerPageInfoRT = rt.type({ export const metricsExplorerPageInfoRT = rt.type({
total: rt.number, total: rt.number,
afterKey: rt.union([rt.string, rt.null]), afterKey: rt.union([rt.string, rt.null, afterKeyObjectRT]),
}); });
export const metricsExplorerColumnTypeRT = rt.keyof({ 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])), rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])),
]); ]);
export const metricsExplorerSeriesRT = rt.type({ export const metricsExplorerSeriesRT = rt.intersection([
id: rt.string, rt.type({
columns: rt.array(metricsExplorerColumnRT), id: rt.string,
rows: rt.array(metricsExplorerRowRT), columns: rt.array(metricsExplorerColumnRT),
}); rows: rt.array(metricsExplorerRowRT),
}),
rt.partial({
keys: rt.array(rt.string),
}),
]);
export const metricsExplorerResponseRT = rt.type({ export const metricsExplorerResponseRT = rt.type({
series: rt.array(metricsExplorerSeriesRT), series: rt.array(metricsExplorerSeriesRT),

View file

@ -138,7 +138,7 @@ export const Expressions: React.FC<Props> = props => {
]); ]);
const onGroupByChange = useCallback( const onGroupByChange = useCallback(
(group: string | null) => { (group: string | null | string[]) => {
setAlertParams('groupBy', group || ''); setAlertParams('groupBy', group || '');
}, },
[setAlertParams] [setAlertParams]
@ -206,7 +206,10 @@ export const Expressions: React.FC<Props> = props => {
convertKueryToElasticSearchQuery(md.currentOptions.filterQuery, derivedIndexPattern) || '' convertKueryToElasticSearchQuery(md.currentOptions.filterQuery, derivedIndexPattern) || ''
); );
} else if (md && md.currentOptions?.groupBy && md.series) { } 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('filterQueryText', filter);
setAlertParams( setAlertParams(
'filterQuery', 'filterQuery',

View file

@ -35,7 +35,7 @@ export enum AGGREGATION_TYPES {
export interface MetricThresholdAlertParams { export interface MetricThresholdAlertParams {
criteria?: MetricExpression[]; criteria?: MetricExpression[];
groupBy?: string; groupBy?: string | string[];
filterQuery?: string; filterQuery?: string;
sourceId?: string; sourceId?: string;
} }

View file

@ -36,6 +36,7 @@ import { getChartTheme } from './helpers/get_chart_theme';
import { useKibanaUiSetting } from '../../../../utils/use_kibana_ui_setting'; import { useKibanaUiSetting } from '../../../../utils/use_kibana_ui_setting';
import { calculateDomain } from './helpers/calculate_domain'; import { calculateDomain } from './helpers/calculate_domain';
import { useKibana, useUiSetting } from '../../../../../../../../src/plugins/kibana_react/public'; import { useKibana, useUiSetting } from '../../../../../../../../src/plugins/kibana_react/public';
import { ChartTitle } from './chart_title';
interface Props { interface Props {
title?: string | null; title?: string | null;
@ -92,16 +93,17 @@ export const MetricsExplorerChart = ({
chartOptions.yAxisMode === MetricsExplorerYAxisMode.fromZero chartOptions.yAxisMode === MetricsExplorerYAxisMode.fromZero
? { ...dataDomain, min: 0 } ? { ...dataDomain, min: 0 }
: dataDomain; : dataDomain;
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
{options.groupBy ? ( {options.groupBy ? (
<EuiTitle size="xs"> <EuiTitle size="xs">
<EuiFlexGroup alignItems="center"> <EuiFlexGroup alignItems="center">
<ChartTitle> <ChartTitleContainer>
<EuiToolTip content={title} anchorClassName="metricsExplorerTitleAnchor"> <EuiToolTip content={title} anchorClassName="metricsExplorerTitleAnchor">
<span>{title}</span> <ChartTitle series={series} />
</EuiToolTip> </EuiToolTip>
</ChartTitle> </ChartTitleContainer>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<MetricsExplorerChartContextMenu <MetricsExplorerChartContextMenu
timeRange={timeRange} timeRange={timeRange}
@ -170,7 +172,7 @@ export const MetricsExplorerChart = ({
); );
}; };
const ChartTitle = euiStyled.div` const ChartTitleContainer = euiStyled.div`
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View file

@ -39,15 +39,16 @@ export interface Props {
const fieldToNodeType = ( const fieldToNodeType = (
source: SourceConfiguration, source: SourceConfiguration,
field: string groupBy: string | string[]
): InventoryItemType | undefined => { ): InventoryItemType | undefined => {
if (source.fields.host === field) { const fields = Array.isArray(groupBy) ? groupBy : [groupBy];
if (fields.includes(source.fields.host)) {
return 'host'; return 'host';
} }
if (source.fields.pod === field) { if (fields.includes(source.fields.pod)) {
return 'pod'; return 'pod';
} }
if (source.fields.container === field) { if (fields.includes(source.fields.container)) {
return 'container'; return 'container';
} }
}; };
@ -88,10 +89,16 @@ export const MetricsExplorerChartContextMenu: React.FC<Props> = ({
// onFilter needs check for Typescript even though it's // onFilter needs check for Typescript even though it's
// covered by supportFiltering variable // covered by supportFiltering variable
if (supportFiltering && onFilter) { 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); 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 // Only display the "Add Filter" option if it's supported
const filterByItem = supportFiltering const filterByItem = supportFiltering

View file

@ -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 (
<EuiFlexGroup gutterSize="xs">
{keys.map((name, i) => (
<Fragment key={name}>
<EuiFlexItem grow={false}>
<EuiText size="m" color={keys.length - 1 > i ? 'subdued' : 'default'}>
<strong>{name}</strong>
</EuiText>
</EuiFlexItem>
{keys.length - 1 > i && (
<EuiFlexItem grow={false}>
<EuiText size="m" color="subdued">
<span>/</span>
</EuiText>
</EuiFlexItem>
)}
</Fragment>
))}
</EuiFlexGroup>
);
}
return <span>{series.id}</span>;
};

View file

@ -19,11 +19,13 @@ import { NoData } from '../../../../components/empty_states/no_data';
import { MetricsExplorerChart } from './chart'; import { MetricsExplorerChart } from './chart';
import { SourceQuery } from '../../../../graphql/types'; import { SourceQuery } from '../../../../graphql/types';
type stringOrNull = string | null;
interface Props { interface Props {
loading: boolean; loading: boolean;
options: MetricsExplorerOptions; options: MetricsExplorerOptions;
chartOptions: MetricsExplorerChartOptions; chartOptions: MetricsExplorerChartOptions;
onLoadMore: (afterKey: string | null) => void; onLoadMore: (afterKey: stringOrNull | Record<string, stringOrNull>) => void;
onRefetch: () => void; onRefetch: () => void;
onFilter: (filter: string) => void; onFilter: (filter: string) => void;
onTimeChange: (start: string, end: string) => void; onTimeChange: (start: string, end: string) => void;
@ -74,6 +76,8 @@ export const MetricsExplorerCharts = ({
); );
} }
const and = i18n.translate('xpack.infra.metricsExplorer.andLabel', { defaultMessage: '" and "' });
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<EuiFlexGrid gutterSize="s" columns={data.series.length === 1 ? 1 : 3}> <EuiFlexGrid gutterSize="s" columns={data.series.length === 1 ? 1 : 3}>
@ -105,7 +109,9 @@ export const MetricsExplorerCharts = ({
values={{ values={{
length: data.series.length, length: data.series.length,
total: data.pageInfo.total, total: data.pageInfo.total,
groupBy: options.groupBy, groupBy: Array.isArray(options.groupBy)
? options.groupBy.join(and)
: options.groupBy,
}} }}
/> />
</p> </p>

View file

@ -13,19 +13,25 @@ import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options';
interface Props { interface Props {
options: MetricsExplorerOptions; options: MetricsExplorerOptions;
onChange: (groupBy: string | null) => void; onChange: (groupBy: string | null | string[]) => void;
fields: IFieldType[]; fields: IFieldType[];
} }
export const MetricsExplorerGroupBy = ({ options, onChange, fields }: Props) => { export const MetricsExplorerGroupBy = ({ options, onChange, fields }: Props) => {
const handleChange = useCallback( const handleChange = useCallback(
selectedOptions => { (selectedOptions: Array<{ label: string }>) => {
const groupBy = (selectedOptions.length === 1 && selectedOptions[0].label) || null; const groupBy = selectedOptions.map(option => option.label);
onChange(groupBy); onChange(groupBy);
}, },
[onChange] [onChange]
); );
const selectedOptions = Array.isArray(options.groupBy)
? options.groupBy.map(field => ({ label: field }))
: options.groupBy
? [{ label: options.groupBy }]
: [];
return ( return (
<EuiComboBox <EuiComboBox
placeholder={i18n.translate('xpack.infra.metricsExplorer.groupByLabel', { placeholder={i18n.translate('xpack.infra.metricsExplorer.groupByLabel', {
@ -35,8 +41,8 @@ export const MetricsExplorerGroupBy = ({ options, onChange, fields }: Props) =>
defaultMessage: 'Graph per', defaultMessage: 'Graph per',
})} })}
fullWidth fullWidth
singleSelection={true} singleSelection={false}
selectedOptions={(options.groupBy && [{ label: options.groupBy }]) || []} selectedOptions={selectedOptions}
options={fields options={fields
.filter(f => f.aggregatable && f.type === 'string') .filter(f => f.aggregatable && f.type === 'string')
.map(f => ({ label: f.name }))} .map(f => ({ label: f.name }))}

View file

@ -109,7 +109,21 @@ export const createFilterFromOptions = (
} }
if (options.groupBy) { if (options.groupBy) {
const id = series.id.replace('"', '\\"'); 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 ') }; return { language: 'kuery', query: filters.join(' and ') };
}; };

View file

@ -37,7 +37,7 @@ interface Props {
defaultViewState: MetricExplorerViewState; defaultViewState: MetricExplorerViewState;
onRefresh: () => void; onRefresh: () => void;
onTimeChange: (start: string, end: string) => void; onTimeChange: (start: string, end: string) => void;
onGroupByChange: (groupBy: string | null) => void; onGroupByChange: (groupBy: string | null | string[]) => void;
onFilterQuerySubmit: (query: string) => void; onFilterQuerySubmit: (query: string) => void;
onMetricsChange: (metrics: MetricsExplorerMetric[]) => void; onMetricsChange: (metrics: MetricsExplorerMetric[]) => void;
onAggregationChange: (aggregation: MetricsExplorerAggregation) => void; onAggregationChange: (aggregation: MetricsExplorerAggregation) => void;

View file

@ -30,7 +30,7 @@ export const useMetricsExplorerState = (
derivedIndexPattern: IIndexPattern derivedIndexPattern: IIndexPattern
) => { ) => {
const [refreshSignal, setRefreshSignal] = useState(0); const [refreshSignal, setRefreshSignal] = useState(0);
const [afterKey, setAfterKey] = useState<string | null>(null); const [afterKey, setAfterKey] = useState<string | null | Record<string, string | null>>(null);
const { const {
defaultViewState, defaultViewState,
options, options,
@ -63,7 +63,7 @@ export const useMetricsExplorerState = (
); );
const handleGroupByChange = useCallback( const handleGroupByChange = useCallback(
(groupBy: string | null) => { (groupBy: string | null | string[]) => {
setAfterKey(null); setAfterKey(null);
setOptions({ setOptions({
...options, ...options,

View file

@ -46,7 +46,7 @@ const renderUseMetricsExplorerDataHook = () => {
source, source,
derivedIndexPattern, derivedIndexPattern,
timeRange, timeRange,
afterKey: null as string | null, afterKey: null as string | null | Record<string, string | null>,
signal: 1, signal: 1,
}, },
wrapper, wrapper,

View file

@ -28,7 +28,7 @@ export function useMetricsExplorerData(
source: SourceQuery.Query['source']['configuration'] | undefined, source: SourceQuery.Query['source']['configuration'] | undefined,
derivedIndexPattern: IIndexPattern, derivedIndexPattern: IIndexPattern,
timerange: MetricsExplorerTimeOptions, timerange: MetricsExplorerTimeOptions,
afterKey: string | null, afterKey: string | null | Record<string, string | null>,
signal: any, signal: any,
fetch?: HttpHandler fetch?: HttpHandler
) { ) {

View file

@ -37,7 +37,7 @@ export interface MetricsExplorerChartOptions {
export interface MetricsExplorerOptions { export interface MetricsExplorerOptions {
metrics: MetricsExplorerOptionsMetric[]; metrics: MetricsExplorerOptionsMetric[];
limit?: number; limit?: number;
groupBy?: string; groupBy?: string | string[];
filterQuery?: string; filterQuery?: string;
aggregation: MetricsExplorerAggregation; aggregation: MetricsExplorerAggregation;
forceInterval?: boolean; forceInterval?: boolean;

View file

@ -74,7 +74,7 @@ const getParsedFilterQuery: (
export const getElasticsearchMetricQuery = ( export const getElasticsearchMetricQuery = (
{ metric, aggType, timeUnit, timeSize }: MetricExpressionParams, { metric, aggType, timeUnit, timeSize }: MetricExpressionParams,
timefield: string, timefield: string,
groupBy?: string, groupBy?: string | string[],
filterQuery?: string filterQuery?: string
) => { ) => {
if (aggType === Aggregators.COUNT && metric) { if (aggType === Aggregators.COUNT && metric) {
@ -126,15 +126,21 @@ export const getElasticsearchMetricQuery = (
groupings: { groupings: {
composite: { composite: {
size: 10, size: 10,
sources: [ sources: Array.isArray(groupBy)
{ ? groupBy.map((field, index) => ({
groupBy: { [`groupBy${index}`]: {
terms: { terms: { field },
field: groupBy,
}, },
}, }))
}, : [
], {
groupBy0: {
terms: {
field: groupBy,
},
},
},
],
}, },
aggs: baseAggs, aggs: baseAggs,
}, },
@ -186,7 +192,7 @@ const getMetric: (
params: MetricExpressionParams, params: MetricExpressionParams,
index: string, index: string,
timefield: string, timefield: string,
groupBy: string | undefined, groupBy: string | undefined | string[],
filterQuery: string | undefined filterQuery: string | undefined
) => Promise<Record<string, number>> = async function( ) => Promise<Record<string, number>> = async function(
{ callCluster }, { callCluster },
@ -213,11 +219,13 @@ const getMetric: (
searchBody, searchBody,
bucketSelector, bucketSelector,
afterKeyHandler afterKeyHandler
)) as Array<Aggregation & { key: { groupBy: string } }>; )) as Array<Aggregation & { key: Record<string, string> }>;
return compositeBuckets.reduce( return compositeBuckets.reduce(
(result, bucket) => ({ (result, bucket) => ({
...result, ...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) { async function({ services, params }: AlertExecutorOptions) {
const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as { const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as {
criteria: MetricExpressionParams[]; criteria: MetricExpressionParams[];
groupBy: string | undefined; groupBy: string | undefined | string[];
filterQuery: string | undefined; filterQuery: string | undefined;
sourceId?: string; sourceId?: string;
alertOnNoData: boolean; alertOnNoData: boolean;

View file

@ -62,7 +62,7 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs) {
params: schema.object( params: schema.object(
{ {
criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])), 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( filterQuery: schema.maybe(
schema.string({ schema.string({
validate: validateIsStringElasticsearchJSONFilter, validate: validateIsStringElasticsearchJSONFilter,

View file

@ -64,11 +64,11 @@ export const emptyMetricResponse = {
export const basicCompositeResponse = { export const basicCompositeResponse = {
aggregations: { aggregations: {
groupings: { groupings: {
after_key: 'foo', after_key: { groupBy0: 'foo' },
buckets: [ buckets: [
{ {
key: { key: {
groupBy: 'a', groupBy0: 'a',
}, },
aggregatedIntervals: { aggregatedIntervals: {
buckets: bucketsA, buckets: bucketsA,
@ -76,7 +76,7 @@ export const basicCompositeResponse = {
}, },
{ {
key: { key: {
groupBy: 'b', groupBy0: 'b',
}, },
aggregatedIntervals: { aggregatedIntervals: {
buckets: bucketsB, buckets: bucketsB,
@ -95,11 +95,11 @@ export const basicCompositeResponse = {
export const alternateCompositeResponse = { export const alternateCompositeResponse = {
aggregations: { aggregations: {
groupings: { groupings: {
after_key: 'foo', after_key: { groupBy0: 'foo' },
buckets: [ buckets: [
{ {
key: { key: {
groupBy: 'a', groupBy0: 'a',
}, },
aggregatedIntervals: { aggregatedIntervals: {
buckets: bucketsB, buckets: bucketsB,
@ -107,7 +107,7 @@ export const alternateCompositeResponse = {
}, },
{ {
key: { key: {
groupBy: 'b', groupBy0: 'b',
}, },
aggregatedIntervals: { aggregatedIntervals: {
buckets: bucketsA, buckets: bucketsA,

View file

@ -5,10 +5,12 @@
*/ */
import { isObject, set } from 'lodash'; import { isObject, set } from 'lodash';
import { i18n } from '@kbn/i18n';
import { InfraDatabaseSearchResponse } from '../../../lib/adapters/framework'; import { InfraDatabaseSearchResponse } from '../../../lib/adapters/framework';
import { import {
MetricsExplorerRequestBody, MetricsExplorerRequestBody,
MetricsExplorerResponse, MetricsExplorerResponse,
afterKeyObjectRT,
} from '../../../../common/http_api/metrics_explorer'; } from '../../../../common/http_api/metrics_explorer';
interface GroupingAggregation { interface GroupingAggregation {
@ -24,7 +26,13 @@ interface GroupingAggregation {
} }
const EMPTY_RESPONSE = { 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 }, pageInfo: { total: 0, afterKey: null },
}; };
@ -35,7 +43,25 @@ export const getGroupings = async (
if (!options.groupBy) { if (!options.groupBy) {
return EMPTY_RESPONSE; return EMPTY_RESPONSE;
} }
if (Array.isArray(options.groupBy) && options.groupBy.length === 0) {
return EMPTY_RESPONSE;
}
const limit = options.limit || 9; const limit = options.limit || 9;
const groupBy = Array.isArray(options.groupBy) ? options.groupBy : [options.groupBy];
const filter: Array<Record<string, any>> = [
{
range: {
[options.timerange.field]: {
gte: options.timerange.from,
lte: options.timerange.to,
format: 'epoch_millis',
},
},
},
...groupBy.map(field => ({ exists: { field } })),
];
const params = { const params = {
allowNoIndices: true, allowNoIndices: true,
ignoreUnavailable: true, ignoreUnavailable: true,
@ -51,27 +77,21 @@ export const getGroupings = async (
exists: { field: m.field }, exists: { field: m.field },
})), })),
], ],
filter: [ filter,
{
range: {
[options.timerange.field]: {
gte: options.timerange.from,
lte: options.timerange.to,
format: 'epoch_millis',
},
},
},
] as object[],
}, },
}, },
aggs: { aggs: {
groupingsCount: { groupingsCount: {
cardinality: { field: options.groupBy }, cardinality: {
script: { source: groupBy.map(field => `doc['${field}'].value`).join('+') },
},
}, },
groupings: { groupings: {
composite: { composite: {
size: limit, 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) { 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) { if (options.filterQuery) {
@ -113,11 +137,13 @@ export const getGroupings = async (
const { after_key: afterKey } = groupings; const { after_key: afterKey } = groupings;
return { return {
series: groupings.buckets.map(bucket => { 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: { pageInfo: {
total: groupingsCount.value, total: groupingsCount.value,
afterKey: afterKey && groupings.buckets.length === limit ? afterKey.groupBy : null, afterKey: afterKey && groupings.buckets.length === limit ? afterKey : null,
}, },
}; };
}; };

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License. * 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 { KibanaRequest, RequestHandlerContext } from 'src/core/server';
import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter'; import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter';
import { import {
@ -38,9 +38,21 @@ export const populateSeriesWithTSVBData = (
} }
// Set the filter for the group by or match everything // Set the filter for the group by or match everything
const filters: JsonObject[] = options.groupBy const isGroupBySet =
? [{ match: { [options.groupBy]: series.id } }] 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) { if (options.filterQuery) {
try { try {
const filterQuery = JSON.parse(options.filterQuery); const filterQuery = JSON.parse(options.filterQuery);

View file

@ -36,11 +36,9 @@ export default function({ getService }: FtrProviderContext) {
{ {
aggregation: 'avg', aggregation: 'avg',
field: 'system.cpu.user.pct', field: 'system.cpu.user.pct',
rate: false,
}, },
{ {
aggregation: 'count', aggregation: 'count',
rate: false,
}, },
], ],
}; };
@ -52,7 +50,7 @@ export default function({ getService }: FtrProviderContext) {
const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); const body = decodeOrThrow(metricsExplorerResponseRT)(response.body);
expect(body.series).length(1); expect(body.series).length(1);
const firstSeries = first(body.series); 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.columns).to.eql([
{ name: 'timestamp', type: 'date' }, { name: 'timestamp', type: 'date' },
{ name: 'metric_0', type: 'number' }, { name: 'metric_0', type: 'number' },
@ -81,7 +79,6 @@ export default function({ getService }: FtrProviderContext) {
{ {
aggregation: 'avg', aggregation: 'avg',
field: 'system.cpu.user.pct', field: 'system.cpu.user.pct',
rate: false,
}, },
], ],
}; };
@ -93,7 +90,7 @@ export default function({ getService }: FtrProviderContext) {
const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); const body = decodeOrThrow(metricsExplorerResponseRT)(response.body);
expect(body.series).length(1); expect(body.series).length(1);
const firstSeries = first(body.series); 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.columns).to.eql([
{ name: 'timestamp', type: 'date' }, { name: 'timestamp', type: 'date' },
{ name: 'metric_0', type: 'number' }, { name: 'metric_0', type: 'number' },
@ -124,7 +121,7 @@ export default function({ getService }: FtrProviderContext) {
const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); const body = decodeOrThrow(metricsExplorerResponseRT)(response.body);
expect(body.series).length(1); expect(body.series).length(1);
const firstSeries = first(body.series); 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.columns).to.eql([]);
expect(firstSeries.rows).to.have.length(0); expect(firstSeries.rows).to.have.length(0);
}); });
@ -144,7 +141,6 @@ export default function({ getService }: FtrProviderContext) {
metrics: [ metrics: [
{ {
aggregation: 'count', aggregation: 'count',
rate: false,
}, },
], ],
}; };
@ -169,10 +165,55 @@ export default function({ getService }: FtrProviderContext) {
timestamp: 1547571300000, timestamp: 1547571300000,
}); });
expect(body.pageInfo).to.eql({ expect(body.pageInfo).to.eql({
afterKey: 'system.fsstat', afterKey: { groupBy0: 'system.fsstat' },
total: 12, 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', () => { describe('without data', () => {
@ -191,7 +232,6 @@ export default function({ getService }: FtrProviderContext) {
{ {
aggregation: 'avg', aggregation: 'avg',
field: 'system.cpu.user.pct', field: 'system.cpu.user.pct',
rate: false,
}, },
], ],
}; };
@ -225,7 +265,6 @@ export default function({ getService }: FtrProviderContext) {
{ {
aggregation: 'avg', aggregation: 'avg',
field: 'system.cpu.user.pct', field: 'system.cpu.user.pct',
rate: false,
}, },
], ],
}; };