[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:
parent
a7c2db73c5
commit
59ae529322
|
@ -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),
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>;
|
||||||
|
};
|
|
@ -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>
|
||||||
|
|
|
@ -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 }))}
|
||||||
|
|
|
@ -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 ') };
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue