diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md new file mode 100644 index 00000000000000..de0d41286c0bbb --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfig](./kibana-plugin-plugins-data-public.aggconfig.md) > [getTimeShift](./kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md) + +## AggConfig.getTimeShift() method + +Signature: + +```typescript +getTimeShift(): undefined | moment.Duration; +``` +Returns: + +`undefined | moment.Duration` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md new file mode 100644 index 00000000000000..024b0766ffd7b0 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfig](./kibana-plugin-plugins-data-public.aggconfig.md) > [hasTimeShift](./kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md) + +## AggConfig.hasTimeShift() method + +Signature: + +```typescript +hasTimeShift(): boolean; +``` +Returns: + +`boolean` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.md index d4a8eddf51cfcd..a96626d1a485d7 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.md @@ -46,8 +46,10 @@ export declare class AggConfig | [getRequestAggs()](./kibana-plugin-plugins-data-public.aggconfig.getrequestaggs.md) | | | | [getResponseAggs()](./kibana-plugin-plugins-data-public.aggconfig.getresponseaggs.md) | | | | [getTimeRange()](./kibana-plugin-plugins-data-public.aggconfig.gettimerange.md) | | | +| [getTimeShift()](./kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md) | | | | [getValue(bucket)](./kibana-plugin-plugins-data-public.aggconfig.getvalue.md) | | | | [getValueBucketPath()](./kibana-plugin-plugins-data-public.aggconfig.getvaluebucketpath.md) | | Returns the bucket path containing the main value the agg will produce (e.g. for sum of bytes it will point to the sum, for median it will point to the 50 percentile in the percentile multi value bucket) | +| [hasTimeShift()](./kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md) | | | | [isFilterable()](./kibana-plugin-plugins-data-public.aggconfig.isfilterable.md) | | | | [makeLabel(percentageMode)](./kibana-plugin-plugins-data-public.aggconfig.makelabel.md) | | | | [nextId(list)](./kibana-plugin-plugins-data-public.aggconfig.nextid.md) | static | Calculate the next id based on the ids in this list {array} list - a list of objects with id properties | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.forcenow.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.forcenow.md new file mode 100644 index 00000000000000..8040c2939e2e45 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.forcenow.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [forceNow](./kibana-plugin-plugins-data-public.aggconfigs.forcenow.md) + +## AggConfigs.forceNow property + +Signature: + +```typescript +forceNow?: Date; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md new file mode 100644 index 00000000000000..1f8bc1300a0a86 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md @@ -0,0 +1,72 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [getSearchSourceTimeFilter](./kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md) + +## AggConfigs.getSearchSourceTimeFilter() method + +Signature: + +```typescript +getSearchSourceTimeFilter(forceNow?: Date): RangeFilter[] | { + meta: { + index: string | undefined; + params: {}; + alias: string; + disabled: boolean; + negate: boolean; + }; + query: { + bool: { + should: { + bool: { + filter: { + range: { + [x: string]: { + gte: string; + lte: string; + }; + }; + }[]; + }; + }[]; + minimum_should_match: number; + }; + }; + }[]; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| forceNow | Date | | + +Returns: + +`RangeFilter[] | { + meta: { + index: string | undefined; + params: {}; + alias: string; + disabled: boolean; + negate: boolean; + }; + query: { + bool: { + should: { + bool: { + filter: { + range: { + [x: string]: { + gte: string; + lte: string; + }; + }; + }[]; + }; + }[]; + minimum_should_match: number; + }; + }; + }[]` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md new file mode 100644 index 00000000000000..d15ccbc5dc0a1c --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [getTimeShiftInterval](./kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md) + +## AggConfigs.getTimeShiftInterval() method + +Signature: + +```typescript +getTimeShiftInterval(): moment.Duration | undefined; +``` +Returns: + +`moment.Duration | undefined` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md new file mode 100644 index 00000000000000..44ab25cf30eb2b --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [getTimeShifts](./kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md) + +## AggConfigs.getTimeShifts() method + +Signature: + +```typescript +getTimeShifts(): Record; +``` +Returns: + +`Record` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md new file mode 100644 index 00000000000000..db31e549666b45 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [hasTimeShifts](./kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md) + +## AggConfigs.hasTimeShifts() method + +Signature: + +```typescript +hasTimeShifts(): boolean; +``` +Returns: + +`boolean` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md index 02e9a63d95ba37..45333b6767cace 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md @@ -22,6 +22,7 @@ export declare class AggConfigs | --- | --- | --- | --- | | [aggs](./kibana-plugin-plugins-data-public.aggconfigs.aggs.md) | | IAggConfig[] | | | [createAggConfig](./kibana-plugin-plugins-data-public.aggconfigs.createaggconfig.md) | | <T extends AggConfig = AggConfig>(params: CreateAggConfigParams, { addToAggConfigs }?: {
addToAggConfigs?: boolean | undefined;
}) => T | | +| [forceNow](./kibana-plugin-plugins-data-public.aggconfigs.forcenow.md) | | Date | | | [hierarchical](./kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md) | | boolean | | | [indexPattern](./kibana-plugin-plugins-data-public.aggconfigs.indexpattern.md) | | IndexPattern | | | [timeFields](./kibana-plugin-plugins-data-public.aggconfigs.timefields.md) | | string[] | | @@ -43,8 +44,14 @@ export declare class AggConfigs | [getRequestAggs()](./kibana-plugin-plugins-data-public.aggconfigs.getrequestaggs.md) | | | | [getResponseAggById(id)](./kibana-plugin-plugins-data-public.aggconfigs.getresponseaggbyid.md) | | Find a response agg by it's id. This may be an agg in the aggConfigs, or one created specifically for a response value | | [getResponseAggs()](./kibana-plugin-plugins-data-public.aggconfigs.getresponseaggs.md) | | Gets the AggConfigs (and possibly ResponseAggConfigs) that represent the values that will be produced when all aggs are run.With multi-value metric aggs it is possible for a single agg request to result in multiple agg values, which is why the length of a vis' responseValuesAggs may be different than the vis' aggs {array\[AggConfig\]} | +| [getSearchSourceTimeFilter(forceNow)](./kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md) | | | +| [getTimeShiftInterval()](./kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md) | | | +| [getTimeShifts()](./kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md) | | | +| [hasTimeShifts()](./kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md) | | | | [jsonDataEquals(aggConfigs)](./kibana-plugin-plugins-data-public.aggconfigs.jsondataequals.md) | | Data-by-data comparison of this Aggregation Ignores the non-array indexes | | [onSearchRequestStart(searchSource, options)](./kibana-plugin-plugins-data-public.aggconfigs.onsearchrequeststart.md) | | | +| [postFlightTransform(response)](./kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md) | | | +| [setForceNow(now)](./kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md) | | | | [setTimeFields(timeFields)](./kibana-plugin-plugins-data-public.aggconfigs.settimefields.md) | | | | [setTimeRange(timeRange)](./kibana-plugin-plugins-data-public.aggconfigs.settimerange.md) | | | | [toDsl()](./kibana-plugin-plugins-data-public.aggconfigs.todsl.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md new file mode 100644 index 00000000000000..b34fda40a30895 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [postFlightTransform](./kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md) + +## AggConfigs.postFlightTransform() method + +Signature: + +```typescript +postFlightTransform(response: IEsSearchResponse): IEsSearchResponse; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| response | IEsSearchResponse<any> | | + +Returns: + +`IEsSearchResponse` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md new file mode 100644 index 00000000000000..60a1bfe0872faf --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [setForceNow](./kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md) + +## AggConfigs.setForceNow() method + +Signature: + +```typescript +setForceNow(now: Date | undefined): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| now | Date | undefined | | + +Returns: + +`void` + diff --git a/src/plugins/data/common/search/aggs/agg_config.ts b/src/plugins/data/common/search/aggs/agg_config.ts index 283d276a22904b..3c83b5bdf6084b 100644 --- a/src/plugins/data/common/search/aggs/agg_config.ts +++ b/src/plugins/data/common/search/aggs/agg_config.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import moment from 'moment'; import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { Assign, Ensure } from '@kbn/utility-types'; @@ -20,6 +21,7 @@ import { import { IAggType } from './agg_type'; import { writeParams } from './agg_params'; import { IAggConfigs } from './agg_configs'; +import { parseTimeShift } from './utils'; type State = string | number | boolean | null | undefined | SerializableState; @@ -172,6 +174,31 @@ export class AggConfig { return _.get(this.params, key); } + hasTimeShift(): boolean { + return Boolean(this.getParam('timeShift')); + } + + getTimeShift(): undefined | moment.Duration { + const rawTimeShift = this.getParam('timeShift'); + if (!rawTimeShift) return undefined; + const parsedTimeShift = parseTimeShift(rawTimeShift); + if (parsedTimeShift === 'invalid') { + throw new Error(`could not parse time shift ${rawTimeShift}`); + } + if (parsedTimeShift === 'previous') { + const timeShiftInterval = this.aggConfigs.getTimeShiftInterval(); + if (timeShiftInterval) { + return timeShiftInterval; + } else if (!this.aggConfigs.timeRange) { + return; + } + return moment.duration( + moment(this.aggConfigs.timeRange.to).diff(this.aggConfigs.timeRange.from) + ); + } + return parsedTimeShift; + } + write(aggs?: IAggConfigs) { return writeParams(this.type.params, this, aggs); } diff --git a/src/plugins/data/common/search/aggs/agg_configs.test.ts b/src/plugins/data/common/search/aggs/agg_configs.test.ts index 28102544ae0553..72ea64791fa5b3 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.test.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.test.ts @@ -14,6 +14,7 @@ import { mockAggTypesRegistry } from './test_helpers'; import type { IndexPatternField } from '../../index_patterns'; import { IndexPattern } from '../../index_patterns/index_patterns/index_pattern'; import { stubIndexPattern, stubIndexPatternWithFields } from '../../../common/stubs'; +import { IEsSearchResponse } from '..'; describe('AggConfigs', () => { let indexPattern: IndexPattern; @@ -332,6 +333,109 @@ describe('AggConfigs', () => { }); }); + it('inserts a time split filters agg if there are multiple time shifts', () => { + const configStates = [ + { + enabled: true, + type: 'terms', + schema: 'segment', + params: { field: 'clientip', size: 10 }, + }, + { enabled: true, type: 'avg', schema: 'metric', params: { field: 'bytes' } }, + { + enabled: true, + type: 'sum', + schema: 'metric', + params: { field: 'bytes', timeShift: '1d' }, + }, + ]; + indexPattern.fields.push({ + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + filterable: true, + searchable: true, + } as IndexPatternField); + + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); + ac.timeFields = ['timestamp']; + ac.timeRange = { + from: '2021-05-05T00:00:00.000Z', + to: '2021-05-10T00:00:00.000Z', + }; + const dsl = ac.toDsl(); + + const terms = ac.byName('terms')[0]; + const avg = ac.byName('avg')[0]; + const sum = ac.byName('sum')[0]; + + expect(dsl[terms.id].aggs.time_offset_split.filters.filters).toMatchInlineSnapshot(` + Object { + "0": Object { + "range": Object { + "timestamp": Object { + "gte": "2021-05-05T00:00:00.000Z", + "lte": "2021-05-10T00:00:00.000Z", + }, + }, + }, + "86400000": Object { + "range": Object { + "timestamp": Object { + "gte": "2021-05-04T00:00:00.000Z", + "lte": "2021-05-09T00:00:00.000Z", + }, + }, + }, + } + `); + expect(dsl[terms.id].aggs.time_offset_split.aggs).toHaveProperty(avg.id); + expect(dsl[terms.id].aggs.time_offset_split.aggs).toHaveProperty(sum.id); + }); + + it('does not insert a time split if there is a single time shift', () => { + const configStates = [ + { + enabled: true, + type: 'terms', + schema: 'segment', + params: { field: 'clientip', size: 10 }, + }, + { + enabled: true, + type: 'avg', + schema: 'metric', + params: { + field: 'bytes', + timeShift: '1d', + }, + }, + { + enabled: true, + type: 'sum', + schema: 'metric', + params: { field: 'bytes', timeShift: '1d' }, + }, + ]; + + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); + ac.timeFields = ['timestamp']; + ac.timeRange = { + from: '2021-05-05T00:00:00.000Z', + to: '2021-05-10T00:00:00.000Z', + }; + const dsl = ac.toDsl(); + + const terms = ac.byName('terms')[0]; + const avg = ac.byName('avg')[0]; + const sum = ac.byName('sum')[0]; + + expect(dsl[terms.id].aggs).not.toHaveProperty('time_offset_split'); + expect(dsl[terms.id].aggs).toHaveProperty(avg.id); + expect(dsl[terms.id].aggs).toHaveProperty(sum.id); + }); + it('writes multiple metric aggregations at every level if the vis is hierarchical', () => { const configStates = [ { enabled: true, type: 'terms', schema: 'segment', params: { field: 'bytes', orderBy: 1 } }, @@ -426,4 +530,246 @@ describe('AggConfigs', () => { ); }); }); + + describe('#postFlightTransform', () => { + it('merges together splitted responses for multiple shifts', () => { + indexPattern = stubIndexPattern as IndexPattern; + indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); + const configStates = [ + { + enabled: true, + type: 'terms', + schema: 'segment', + params: { field: 'clientip', size: 10 }, + }, + { + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '1d' }, + }, + { + enabled: true, + type: 'avg', + schema: 'metric', + params: { + field: 'bytes', + timeShift: '1d', + }, + }, + { + enabled: true, + type: 'sum', + schema: 'metric', + params: { field: 'bytes' }, + }, + ]; + + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); + ac.timeFields = ['@timestamp']; + ac.timeRange = { + from: '2021-05-05T00:00:00.000Z', + to: '2021-05-10T00:00:00.000Z', + }; + // 1 terms bucket (A), with 2 date buckets (7th and 8th of May) + // the bucket keys of the shifted time range will be shifted forward + const response = { + rawResponse: { + aggregations: { + '1': { + buckets: [ + { + key: 'A', + time_offset_split: { + buckets: { + '0': { + 2: { + buckets: [ + { + // 2021-05-07 + key: 1620345600000, + 3: { + value: 1.1, + }, + 4: { + value: 2.2, + }, + }, + { + // 2021-05-08 + key: 1620432000000, + doc_count: 26, + 3: { + value: 3.3, + }, + 4: { + value: 4.4, + }, + }, + ], + }, + }, + '86400000': { + 2: { + buckets: [ + { + // 2021-05-07 + key: 1620345600000, + doc_count: 13, + 3: { + value: 5.5, + }, + 4: { + value: 6.6, + }, + }, + { + // 2021-05-08 + key: 1620432000000, + 3: { + value: 7.7, + }, + 4: { + value: 8.8, + }, + }, + ], + }, + }, + }, + }, + }, + ], + }, + }, + }, + }; + const mergedResponse = ac.postFlightTransform( + (response as unknown) as IEsSearchResponse + ); + expect(mergedResponse.rawResponse).toEqual({ + aggregations: { + '1': { + buckets: [ + { + '2': { + buckets: [ + { + '4': { + value: 2.2, + }, + // 2021-05-07 + key: 1620345600000, + }, + { + '3': { + value: 5.5, + }, + '4': { + value: 4.4, + }, + doc_count: 26, + doc_count_86400000: 13, + // 2021-05-08 + key: 1620432000000, + }, + { + '3': { + value: 7.7, + }, + // 2021-05-09 + key: 1620518400000, + }, + ], + }, + key: 'A', + }, + ], + }, + }, + }); + }); + + it('shifts date histogram keys and renames doc_count properties for single shift', () => { + indexPattern = stubIndexPattern as IndexPattern; + indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField); + const configStates = [ + { + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '1d' }, + }, + { + enabled: true, + type: 'avg', + schema: 'metric', + params: { + field: 'bytes', + timeShift: '1d', + }, + }, + ]; + + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); + ac.timeFields = ['@timestamp']; + ac.timeRange = { + from: '2021-05-05T00:00:00.000Z', + to: '2021-05-10T00:00:00.000Z', + }; + const response = { + rawResponse: { + aggregations: { + '1': { + buckets: [ + { + // 2021-05-07 + key: 1620345600000, + doc_count: 26, + 2: { + value: 1.1, + }, + }, + { + // 2021-05-08 + key: 1620432000000, + doc_count: 27, + 2: { + value: 2.2, + }, + }, + ], + }, + }, + }, + }; + const mergedResponse = ac.postFlightTransform( + (response as unknown) as IEsSearchResponse + ); + expect(mergedResponse.rawResponse).toEqual({ + aggregations: { + '1': { + buckets: [ + { + '2': { + value: 1.1, + }, + doc_count_86400000: 26, + // 2021-05-08 + key: 1620432000000, + }, + { + '2': { + value: 2.2, + }, + doc_count_86400000: 27, + // 2021-05-09 + key: 1620518400000, + }, + ], + }, + }, + }); + }); + }); }); diff --git a/src/plugins/data/common/search/aggs/agg_configs.ts b/src/plugins/data/common/search/aggs/agg_configs.ts index 2932ef7325aed8..6f8a8d38a4a286 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.ts @@ -6,17 +6,26 @@ * Side Public License, v 1. */ -import _ from 'lodash'; +import moment from 'moment'; +import _, { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { Assign } from '@kbn/utility-types'; - -import { ISearchOptions, ISearchSource } from 'src/plugins/data/public'; +import { Aggregate, Bucket } from '@elastic/elasticsearch/api/types'; + +import { + IEsSearchResponse, + ISearchOptions, + ISearchSource, + RangeFilter, +} from 'src/plugins/data/public'; import { AggConfig, AggConfigSerialized, IAggConfig } from './agg_config'; import { IAggType } from './agg_type'; import { AggTypesRegistryStart } from './agg_types_registry'; import { AggGroupNames } from './agg_groups'; import { IndexPattern } from '../../index_patterns/index_patterns/index_pattern'; -import { TimeRange } from '../../../common'; +import { TimeRange, getTime, isRangeFilter } from '../../../common'; +import { IBucketAggConfig } from './buckets'; +import { insertTimeShiftSplit, mergeTimeShifts } from './utils/time_splits'; function removeParentAggs(obj: any) { for (const prop in obj) { @@ -48,6 +57,8 @@ export interface AggConfigsOptions { export type CreateAggConfigParams = Assign; +export type GenericBucket = Bucket & { [property: string]: Aggregate }; + /** * @name AggConfigs * @@ -66,6 +77,7 @@ export class AggConfigs { public indexPattern: IndexPattern; public timeRange?: TimeRange; public timeFields?: string[]; + public forceNow?: Date; public hierarchical?: boolean = false; private readonly typesRegistry: AggTypesRegistryStart; @@ -92,6 +104,10 @@ export class AggConfigs { this.timeFields = timeFields; } + setForceNow(now: Date | undefined) { + this.forceNow = now; + } + setTimeRange(timeRange: TimeRange) { this.timeRange = timeRange; @@ -183,7 +199,13 @@ export class AggConfigs { let dslLvlCursor: Record; let nestedMetrics: Array<{ config: AggConfig; dsl: Record }> | []; + const timeShifts = this.getTimeShifts(); + const hasMultipleTimeShifts = Object.keys(timeShifts).length > 1; + if (this.hierarchical) { + if (hasMultipleTimeShifts) { + throw new Error('Multiple time shifts not supported for hierarchical metrics'); + } // collect all metrics, and filter out the ones that we won't be copying nestedMetrics = this.aggs .filter(function (agg) { @@ -196,52 +218,67 @@ export class AggConfigs { }; }); } - this.getRequestAggs() - .filter((config: AggConfig) => !config.type.hasNoDsl) - .forEach((config: AggConfig, i: number, list) => { - if (!dslLvlCursor) { - // start at the top level - dslLvlCursor = dslTopLvl; - } else { - const prevConfig: AggConfig = list[i - 1]; - const prevDsl = dslLvlCursor[prevConfig.id]; + const requestAggs = this.getRequestAggs(); + const aggsWithDsl = requestAggs.filter((agg) => !agg.type.hasNoDsl).length; + const timeSplitIndex = this.getAll().findIndex( + (config) => 'splitForTimeShift' in config.type && config.type.splitForTimeShift(config, this) + ); - // advance the cursor and nest under the previous agg, or - // put it on the same level if the previous agg doesn't accept - // sub aggs - dslLvlCursor = prevDsl?.aggs || dslLvlCursor; - } + requestAggs.forEach((config: AggConfig, i: number, list) => { + if (!dslLvlCursor) { + // start at the top level + dslLvlCursor = dslTopLvl; + } else { + const prevConfig: AggConfig = list[i - 1]; + const prevDsl = dslLvlCursor[prevConfig.id]; + + // advance the cursor and nest under the previous agg, or + // put it on the same level if the previous agg doesn't accept + // sub aggs + dslLvlCursor = prevDsl?.aggs || dslLvlCursor; + } + + if (hasMultipleTimeShifts) { + dslLvlCursor = insertTimeShiftSplit(this, config, timeShifts, dslLvlCursor); + } - const dsl = config.type.hasNoDslParams - ? config.toDsl(this) - : (dslLvlCursor[config.id] = config.toDsl(this)); - let subAggs: any; + if (config.type.hasNoDsl) { + return; + } - parseParentAggs(dslLvlCursor, dsl); + const dsl = config.type.hasNoDslParams + ? config.toDsl(this) + : (dslLvlCursor[config.id] = config.toDsl(this)); + let subAggs: any; - if (config.type.type === AggGroupNames.Buckets && i < list.length - 1) { - // buckets that are not the last item in the list accept sub-aggs - subAggs = dsl.aggs || (dsl.aggs = {}); - } + parseParentAggs(dslLvlCursor, dsl); - if (subAggs) { - _.each(subAggs, (agg) => { - parseParentAggs(subAggs, agg); - }); - } - if (subAggs && nestedMetrics) { - nestedMetrics.forEach((agg: any) => { - subAggs[agg.config.id] = agg.dsl; - // if a nested metric agg has parent aggs, we have to add them to every level of the tree - // to make sure "bucket_path" references in the nested metric agg itself are still working - if (agg.dsl.parentAggs) { - Object.entries(agg.dsl.parentAggs).forEach(([parentAggId, parentAgg]) => { - subAggs[parentAggId] = parentAgg; - }); - } - }); - } - }); + if ( + config.type.type === AggGroupNames.Buckets && + (i < aggsWithDsl - 1 || timeSplitIndex > i) + ) { + // buckets that are not the last item in the list of dsl producing aggs or have a time split coming up accept sub-aggs + subAggs = dsl.aggs || (dsl.aggs = {}); + } + + if (subAggs) { + _.each(subAggs, (agg) => { + parseParentAggs(subAggs, agg); + }); + } + if (subAggs && nestedMetrics) { + nestedMetrics.forEach((agg: any) => { + subAggs[agg.config.id] = agg.dsl; + // if a nested metric agg has parent aggs, we have to add them to every level of the tree + // to make sure "bucket_path" references in the nested metric agg itself are still working + if (agg.dsl.parentAggs) { + Object.entries(agg.dsl.parentAggs).forEach(([parentAggId, parentAgg]) => { + subAggs[parentAggId] = parentAgg; + }); + } + }); + } + }); removeParentAggs(dslTopLvl); return dslTopLvl; @@ -289,6 +326,104 @@ export class AggConfigs { ); } + getTimeShifts(): Record { + const timeShifts: Record = {}; + this.getAll() + .filter((agg) => agg.schema === 'metric') + .map((agg) => agg.getTimeShift()) + .forEach((timeShift) => { + if (timeShift) { + timeShifts[String(timeShift.asMilliseconds())] = timeShift; + } else { + timeShifts[0] = moment.duration(0); + } + }); + return timeShifts; + } + + getTimeShiftInterval(): moment.Duration | undefined { + const splitAgg = (this.getAll().filter( + (agg) => agg.type.type === AggGroupNames.Buckets + ) as IBucketAggConfig[]).find((agg) => agg.type.splitForTimeShift(agg, this)); + return splitAgg?.type.getTimeShiftInterval(splitAgg); + } + + hasTimeShifts(): boolean { + return this.getAll().some((agg) => agg.hasTimeShift()); + } + + getSearchSourceTimeFilter(forceNow?: Date) { + if (!this.timeFields || !this.timeRange) { + return []; + } + const timeRange = this.timeRange; + const timeFields = this.timeFields; + const timeShifts = this.getTimeShifts(); + if (!this.hasTimeShifts()) { + return this.timeFields + .map((fieldName) => getTime(this.indexPattern, timeRange, { fieldName, forceNow })) + .filter(isRangeFilter); + } + return [ + { + meta: { + index: this.indexPattern?.id, + params: {}, + alias: '', + disabled: false, + negate: false, + }, + query: { + bool: { + should: Object.entries(timeShifts).map(([, shift]) => { + return { + bool: { + filter: timeFields + .map( + (fieldName) => + [ + getTime(this.indexPattern, timeRange, { fieldName, forceNow }), + fieldName, + ] as [RangeFilter | undefined, string] + ) + .filter(([filter]) => isRangeFilter(filter)) + .map(([filter, field]) => ({ + range: { + [field]: { + gte: moment(filter?.range[field].gte).subtract(shift).toISOString(), + lte: moment(filter?.range[field].lte).subtract(shift).toISOString(), + }, + }, + })), + }, + }; + }), + minimum_should_match: 1, + }, + }, + }, + ]; + } + + postFlightTransform(response: IEsSearchResponse) { + if (!this.hasTimeShifts()) { + return response; + } + const transformedRawResponse = cloneDeep(response.rawResponse); + if (!transformedRawResponse.aggregations) { + transformedRawResponse.aggregations = { + doc_count: response.rawResponse.hits?.total as Aggregate, + }; + } + const aggCursor = transformedRawResponse.aggregations!; + + mergeTimeShifts(this, aggCursor); + return { + ...response, + rawResponse: transformedRawResponse, + }; + } + getRequestAggById(id: string) { return this.aggs.find((agg: AggConfig) => agg.id === id); } diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index f0f3912bf64fea..48ce54bbd61bdb 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -215,6 +215,10 @@ export class AggType< return agg.id; }; + splitForTimeShift(agg: TAggConfig, aggs: IAggConfigs) { + return false; + } + /** * Generic AggType Constructor * diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts index 6230ae897b1702..372d487bcf7a39 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts @@ -166,7 +166,7 @@ export const buildOtherBucketAgg = ( key: string ) => { // make sure there are actually results for the buckets - if (aggregations[aggId].buckets.length < 1) { + if (aggregations[aggId]?.buckets.length < 1) { noAggBucketResults = true; return; } diff --git a/src/plugins/data/common/search/aggs/buckets/bucket_agg_type.ts b/src/plugins/data/common/search/aggs/buckets/bucket_agg_type.ts index e9ed3799b90cf2..d44e634a00fe6b 100644 --- a/src/plugins/data/common/search/aggs/buckets/bucket_agg_type.ts +++ b/src/plugins/data/common/search/aggs/buckets/bucket_agg_type.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +import moment from 'moment'; import { IAggConfig } from '../agg_config'; -import { KBN_FIELD_TYPES } from '../../../../common'; +import { GenericBucket, IAggConfigs, KBN_FIELD_TYPES } from '../../../../common'; import { AggType, AggTypeConfig } from '../agg_type'; import { AggParamType } from '../param_types/agg'; @@ -26,6 +27,14 @@ const bucketType = 'buckets'; interface BucketAggTypeConfig extends AggTypeConfig> { getKey?: (bucket: any, key: any, agg: IAggConfig) => any; + getShiftedKey?: ( + agg: TBucketAggConfig, + key: string | number, + timeShift: moment.Duration + ) => string | number; + orderBuckets?(agg: TBucketAggConfig, a: GenericBucket, b: GenericBucket): number; + splitForTimeShift?(agg: TBucketAggConfig, aggs: IAggConfigs): boolean; + getTimeShiftInterval?(agg: TBucketAggConfig): undefined | moment.Duration; } export class BucketAggType extends AggType< @@ -35,6 +44,22 @@ export class BucketAggType any; type = bucketType; + getShiftedKey( + agg: TBucketAggConfig, + key: string | number, + timeShift: moment.Duration + ): string | number { + return key; + } + + getTimeShiftInterval(agg: TBucketAggConfig): undefined | moment.Duration { + return undefined; + } + + orderBuckets(agg: TBucketAggConfig, a: GenericBucket, b: GenericBucket): number { + return Number(a.key) - Number(b.key); + } + constructor(config: BucketAggTypeConfig) { super(config); @@ -43,6 +68,22 @@ export class BucketAggType { return key || bucket.key; }); + + if (config.getShiftedKey) { + this.getShiftedKey = config.getShiftedKey; + } + + if (config.orderBuckets) { + this.orderBuckets = config.orderBuckets; + } + + if (config.getTimeShiftInterval) { + this.getTimeShiftInterval = config.getTimeShiftInterval; + } + + if (config.splitForTimeShift) { + this.splitForTimeShift = config.splitForTimeShift; + } } } diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts index 4a83ae38d34db9..4cbf6562487b2c 100644 --- a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts @@ -135,6 +135,17 @@ export const getDateHistogramBucketAgg = ({ }, }; }, + getShiftedKey(agg, key, timeShift) { + return moment(key).add(timeShift).valueOf(); + }, + splitForTimeShift(agg, aggs) { + return aggs.hasTimeShifts() && Boolean(aggs.timeFields?.includes(agg.fieldName())); + }, + getTimeShiftInterval(agg) { + const { useNormalizedEsInterval } = agg.params; + const interval = agg.buckets.getInterval(useNormalizedEsInterval); + return interval; + }, params: [ { name: 'field', diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts index 1b876051d009b7..b9329bcb25af37 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.ts @@ -9,6 +9,7 @@ import { noop } from 'lodash'; import { i18n } from '@kbn/i18n'; +import moment from 'moment'; import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { createFilterTerms } from './create_filter/terms'; @@ -179,6 +180,54 @@ export const getTermsBucketAgg = () => return; } + if ( + aggs?.hasTimeShifts() && + Object.keys(aggs?.getTimeShifts()).length > 1 && + aggs.timeRange + ) { + const shift = orderAgg.getTimeShift(); + orderAgg = aggs.createAggConfig( + { + type: 'filtered_metric', + id: orderAgg.id, + params: { + customBucket: aggs + .createAggConfig( + { + type: 'filter', + id: 'shift', + params: { + filter: { + language: 'lucene', + query: { + range: { + [aggs.timeFields![0]]: { + gte: moment(aggs.timeRange.from) + .subtract(shift || 0) + .toISOString(), + lte: moment(aggs.timeRange.to) + .subtract(shift || 0) + .toISOString(), + }, + }, + }, + }, + }, + }, + { + addToAggConfigs: false, + } + ) + .serialize(), + customMetric: orderAgg.serialize(), + }, + enabled: false, + }, + { + addToAggConfigs: false, + } + ); + } if (orderAgg.type.name === 'count') { order._count = dir; return; diff --git a/src/plugins/data/common/search/aggs/metrics/avg_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/avg_fn.test.ts index 05a6e9eeff7d74..0b794617fb96ed 100644 --- a/src/plugins/data/common/search/aggs/metrics/avg_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/avg_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "avg", diff --git a/src/plugins/data/common/search/aggs/metrics/avg_fn.ts b/src/plugins/data/common/search/aggs/metrics/avg_fn.ts index 253013238d10e4..e32de6cd0a83f3 100644 --- a/src/plugins/data/common/search/aggs/metrics/avg_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/avg_fn.ts @@ -62,6 +62,13 @@ export const aggAvg = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.test.ts index 33f20b9a40dc26..ac214c1a1591ce 100644 --- a/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.test.ts @@ -29,6 +29,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "customMetric": undefined, "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "avg_bucket", @@ -42,11 +43,13 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "customMetric": undefined, "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "avg_bucket", }, "json": undefined, + "timeShift": undefined, } `); }); diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.ts b/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.ts index b79e57207ebd81..a980f6ac555a24 100644 --- a/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.ts @@ -77,6 +77,13 @@ export const aggBucketAvg = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.test.ts index 35b765ec0e075c..e6db7665a68ddc 100644 --- a/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.test.ts @@ -29,6 +29,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "customMetric": undefined, "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "max_bucket", @@ -42,11 +43,13 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "customMetric": undefined, "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "max_bucket", }, "json": undefined, + "timeShift": undefined, } `); }); diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.ts b/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.ts index e12a592448334d..0d3e8a5e7f878e 100644 --- a/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.ts @@ -77,6 +77,13 @@ export const aggBucketMax = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.test.ts index 49346036ce6492..22ec55506fe901 100644 --- a/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.test.ts @@ -29,6 +29,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "customMetric": undefined, "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "min_bucket", @@ -42,11 +43,13 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "customMetric": undefined, "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "min_bucket", }, "json": undefined, + "timeShift": undefined, } `); }); diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.ts b/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.ts index ece5c07c6e5f86..3b6c32595909a9 100644 --- a/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.ts @@ -77,6 +77,13 @@ export const aggBucketMin = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.test.ts index 0f5c84a477b06b..0e3370cec14e5d 100644 --- a/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.test.ts @@ -29,6 +29,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "customMetric": undefined, "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "sum_bucket", @@ -42,11 +43,13 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "customMetric": undefined, "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "sum_bucket", }, "json": undefined, + "timeShift": undefined, } `); }); diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.ts b/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.ts index 5fe0ee75bfe38a..ae3502bbc25883 100644 --- a/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.ts @@ -77,6 +77,13 @@ export const aggBucketSum = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts index 8b235edacb59a0..08d64e599d8a9e 100644 --- a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "cardinality", diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts index ee0f72e01e1def..89006761407f74 100644 --- a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts @@ -67,6 +67,13 @@ export const aggCardinality = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/count.ts b/src/plugins/data/common/search/aggs/metrics/count.ts index 8a10d7edb3f835..fac1751290f70d 100644 --- a/src/plugins/data/common/search/aggs/metrics/count.ts +++ b/src/plugins/data/common/search/aggs/metrics/count.ts @@ -31,7 +31,12 @@ export const getCountMetricAgg = () => }; }, getValue(agg, bucket) { - return bucket.doc_count; + const timeShift = agg.getTimeShift(); + if (!timeShift) { + return bucket.doc_count; + } else { + return bucket[`doc_count_${timeShift.asMilliseconds()}`]; + } }, isScalable() { return true; diff --git a/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts index 047b9bbd8517f2..c6736c5b69f7d4 100644 --- a/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts @@ -23,6 +23,7 @@ describe('agg_expression_functions', () => { "id": undefined, "params": Object { "customLabel": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "count", diff --git a/src/plugins/data/common/search/aggs/metrics/count_fn.ts b/src/plugins/data/common/search/aggs/metrics/count_fn.ts index 40c87db57eedca..a3a4bcc16a3913 100644 --- a/src/plugins/data/common/search/aggs/metrics/count_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/count_fn.ts @@ -54,6 +54,13 @@ export const aggCount = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.test.ts index 5eb6d2b7804420..f311ab35a8d0df 100644 --- a/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.test.ts @@ -29,6 +29,7 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "cumulative_sum", @@ -54,6 +55,7 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": "sum", + "timeShift": undefined, }, "schema": undefined, "type": "cumulative_sum", @@ -81,12 +83,14 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "cumulative_sum", }, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, } `); }); diff --git a/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.ts b/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.ts index cba4de1ad11aec..5cdbcfe8575853 100644 --- a/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.ts @@ -81,6 +81,13 @@ export const aggCumulativeSum = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/derivative_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/derivative_fn.test.ts index 1eaca811a2481f..3e4fc838dd398a 100644 --- a/src/plugins/data/common/search/aggs/metrics/derivative_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/derivative_fn.test.ts @@ -29,6 +29,7 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "derivative", @@ -54,6 +55,7 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": "sum", + "timeShift": undefined, }, "schema": undefined, "type": "derivative", @@ -81,12 +83,14 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "derivative", }, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, } `); }); diff --git a/src/plugins/data/common/search/aggs/metrics/derivative_fn.ts b/src/plugins/data/common/search/aggs/metrics/derivative_fn.ts index e27179c7209ade..8bfe808aede8e9 100644 --- a/src/plugins/data/common/search/aggs/metrics/derivative_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/derivative_fn.ts @@ -81,6 +81,13 @@ export const aggDerivative = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts index aa2417bbf84156..00f47d31b0398d 100644 --- a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts @@ -42,7 +42,7 @@ export const getFilteredMetricAgg = () => { getValue(agg, bucket) { const customMetric = agg.getParam('customMetric'); const customBucket = agg.getParam('customBucket'); - return customMetric.getValue(bucket[customBucket.id]); + return bucket && bucket[customBucket.id] && customMetric.getValue(bucket[customBucket.id]); }, getValueBucketPath(agg) { const customBucket = agg.getParam('customBucket'); diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts index 22e97fe18b604c..d1ce6ff4639035 100644 --- a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts @@ -28,6 +28,7 @@ describe('agg_expression_functions', () => { "customBucket": undefined, "customLabel": undefined, "customMetric": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "filtered_metric", @@ -40,10 +41,12 @@ describe('agg_expression_functions', () => { "customBucket": undefined, "customLabel": undefined, "customMetric": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "filtered_metric", }, + "timeShift": undefined, } `); }); diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts index 6a7ff5fa5fd40e..0b3d3acd3a603f 100644 --- a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts @@ -72,6 +72,13 @@ export const aggFilteredMetric = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.test.ts index c48233e84404c6..50b5f5b60376b6 100644 --- a/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "geo_bounds", diff --git a/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.ts b/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.ts index 19d2dabc843dd9..b2cfad1805b9f5 100644 --- a/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.ts @@ -67,6 +67,13 @@ export const aggGeoBounds = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.test.ts index e984df13527ca9..889ed29c63ee14 100644 --- a/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "geo_centroid", diff --git a/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.ts b/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.ts index 1cc11c345e9ba0..9215f7afb4c6d1 100644 --- a/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.ts @@ -67,6 +67,13 @@ export const aggGeoCentroid = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/max_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/max_fn.test.ts index d94e01927c851b..021c5aac69e102 100644 --- a/src/plugins/data/common/search/aggs/metrics/max_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/max_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "max", diff --git a/src/plugins/data/common/search/aggs/metrics/max_fn.ts b/src/plugins/data/common/search/aggs/metrics/max_fn.ts index 7eac992680737d..7a1d8ad22fb7ed 100644 --- a/src/plugins/data/common/search/aggs/metrics/max_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/max_fn.ts @@ -62,6 +62,13 @@ export const aggMax = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/median.ts b/src/plugins/data/common/search/aggs/metrics/median.ts index bad4c7baf173f6..4fdb1ce6b7d81c 100644 --- a/src/plugins/data/common/search/aggs/metrics/median.ts +++ b/src/plugins/data/common/search/aggs/metrics/median.ts @@ -46,7 +46,7 @@ export const getMedianMetricAgg = () => { { name: 'percents', default: [50], shouldShow: () => false, serialize: () => undefined }, ], getValue(agg, bucket) { - return bucket[agg.id].values['50.0']; + return bucket[agg.id]?.values['50.0']; }, }); }; diff --git a/src/plugins/data/common/search/aggs/metrics/median_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/median_fn.test.ts index e70520b743e179..7ff7f18cdbc02c 100644 --- a/src/plugins/data/common/search/aggs/metrics/median_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/median_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "median", diff --git a/src/plugins/data/common/search/aggs/metrics/median_fn.ts b/src/plugins/data/common/search/aggs/metrics/median_fn.ts index 1c0afd81a63c4b..a9537e1f99ca41 100644 --- a/src/plugins/data/common/search/aggs/metrics/median_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/median_fn.ts @@ -67,6 +67,13 @@ export const aggMedian = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts index 3ebb771413665d..6ddb0fdd9410d4 100644 --- a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts +++ b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts @@ -11,7 +11,8 @@ import { AggType, AggTypeConfig } from '../agg_type'; import { AggParamType } from '../param_types/agg'; import { AggConfig } from '../agg_config'; import { METRIC_TYPES } from './metric_agg_types'; -import { FieldTypes } from '../param_types'; +import { BaseParamType, FieldTypes } from '../param_types'; +import { AggGroupNames } from '../agg_groups'; export interface IMetricAggConfig extends AggConfig { type: InstanceType; @@ -47,6 +48,14 @@ export class MetricAggType) { super(config); + this.params.push( + new BaseParamType({ + name: 'timeShift', + type: 'string', + write: () => {}, + }) as MetricAggParam + ); + this.getValue = config.getValue || ((agg, bucket) => { @@ -69,6 +78,14 @@ export class MetricAggType false); + + // split at this point if there are time shifts and this is the first metric + this.splitForTimeShift = (agg, aggs) => + aggs.hasTimeShifts() && + aggs.byType(AggGroupNames.Metrics)[0] === agg && + !aggs + .byType(AggGroupNames.Buckets) + .some((bucketAgg) => bucketAgg.type.splitForTimeShift(bucketAgg, aggs)); } } diff --git a/src/plugins/data/common/search/aggs/metrics/min_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/min_fn.test.ts index ea2d2cd23edaea..fee4b28882408d 100644 --- a/src/plugins/data/common/search/aggs/metrics/min_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/min_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "min", diff --git a/src/plugins/data/common/search/aggs/metrics/min_fn.ts b/src/plugins/data/common/search/aggs/metrics/min_fn.ts index 6dfbac1ecb8b4d..a97834f310a49e 100644 --- a/src/plugins/data/common/search/aggs/metrics/min_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/min_fn.ts @@ -62,6 +62,13 @@ export const aggMin = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.test.ts index bde90c563afc11..645519a6683761 100644 --- a/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.test.ts @@ -30,6 +30,7 @@ describe('agg_expression_functions', () => { "json": undefined, "metricAgg": undefined, "script": undefined, + "timeShift": undefined, "window": undefined, }, "schema": undefined, @@ -59,6 +60,7 @@ describe('agg_expression_functions', () => { "json": undefined, "metricAgg": "sum", "script": "test", + "timeShift": undefined, "window": 10, }, "schema": undefined, @@ -88,6 +90,7 @@ describe('agg_expression_functions', () => { "json": undefined, "metricAgg": undefined, "script": undefined, + "timeShift": undefined, "window": undefined, }, "schema": undefined, @@ -96,6 +99,7 @@ describe('agg_expression_functions', () => { "json": undefined, "metricAgg": undefined, "script": undefined, + "timeShift": undefined, "window": undefined, } `); diff --git a/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.ts b/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.ts index 667c585226a528..1637dad561c375 100644 --- a/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.ts @@ -94,6 +94,13 @@ export const aggMovingAvg = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.test.ts index 9328597b24cfaa..873765374c80a6 100644 --- a/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, "values": undefined, }, "schema": undefined, @@ -51,6 +52,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, "values": Array [ 1, 2, diff --git a/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.ts b/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.ts index 7929a01c0b5893..60a2882fcec581 100644 --- a/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.ts @@ -74,6 +74,13 @@ export const aggPercentileRanks = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/percentiles_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/percentiles_fn.test.ts index 0d71df240d1226..468da036cea88f 100644 --- a/src/plugins/data/common/search/aggs/metrics/percentiles_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/percentiles_fn.test.ts @@ -28,6 +28,7 @@ describe('agg_expression_functions', () => { "field": "machine.os.keyword", "json": undefined, "percents": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "percentiles", @@ -56,6 +57,7 @@ describe('agg_expression_functions', () => { 2, 3, ], + "timeShift": undefined, }, "schema": undefined, "type": "percentiles", diff --git a/src/plugins/data/common/search/aggs/metrics/percentiles_fn.ts b/src/plugins/data/common/search/aggs/metrics/percentiles_fn.ts index fa5120dfc3b97f..1a746a86cbcd57 100644 --- a/src/plugins/data/common/search/aggs/metrics/percentiles_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/percentiles_fn.ts @@ -74,6 +74,13 @@ export const aggPercentiles = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.test.ts index 065ef8021cbda2..aa73d5c44dd7fc 100644 --- a/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.test.ts @@ -29,6 +29,7 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "serial_diff", @@ -54,6 +55,7 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": "sum", + "timeShift": undefined, }, "schema": undefined, "type": "serial_diff", @@ -81,12 +83,14 @@ describe('agg_expression_functions', () => { "customMetric": undefined, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "serial_diff", }, "json": undefined, "metricAgg": undefined, + "timeShift": undefined, } `); }); diff --git a/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.ts b/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.ts index 925d85774c7ad6..8460cb891f1e4c 100644 --- a/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.ts @@ -81,6 +81,13 @@ export const aggSerialDiff = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/single_percentile_fn.ts b/src/plugins/data/common/search/aggs/metrics/single_percentile_fn.ts index e7ef22c6faeee6..edf69031c31ace 100644 --- a/src/plugins/data/common/search/aggs/metrics/single_percentile_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/single_percentile_fn.ts @@ -74,6 +74,13 @@ export const aggSinglePercentile = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.test.ts index 9aaf82e65812b8..849987695dc7c4 100644 --- a/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "std_dev", diff --git a/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.ts b/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.ts index 80787a3383c6b5..c181065d2416e7 100644 --- a/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.ts @@ -67,6 +67,13 @@ export const aggStdDeviation = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts index e19fc072e1cd98..f4d4fb5451dcda 100644 --- a/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts @@ -27,6 +27,7 @@ describe('agg_expression_functions', () => { "customLabel": undefined, "field": "machine.os.keyword", "json": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "sum", diff --git a/src/plugins/data/common/search/aggs/metrics/sum_fn.ts b/src/plugins/data/common/search/aggs/metrics/sum_fn.ts index d0175e0c8fafe4..d8e03d28bb12a7 100644 --- a/src/plugins/data/common/search/aggs/metrics/sum_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/sum_fn.ts @@ -62,6 +62,13 @@ export const aggSum = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/top_hit_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/top_hit_fn.test.ts index e9d6a619a9cd6b..2f8ef74b5c2f0c 100644 --- a/src/plugins/data/common/search/aggs/metrics/top_hit_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/top_hit_fn.test.ts @@ -32,6 +32,7 @@ describe('agg_expression_functions', () => { "size": undefined, "sortField": undefined, "sortOrder": undefined, + "timeShift": undefined, }, "schema": undefined, "type": "top_hits", @@ -64,6 +65,7 @@ describe('agg_expression_functions', () => { "size": 6, "sortField": "_score", "sortOrder": "asc", + "timeShift": undefined, }, "schema": "whatever", "type": "top_hits", diff --git a/src/plugins/data/common/search/aggs/metrics/top_hit_fn.ts b/src/plugins/data/common/search/aggs/metrics/top_hit_fn.ts index 85b54f16954937..bc20f19253eec7 100644 --- a/src/plugins/data/common/search/aggs/metrics/top_hit_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/top_hit_fn.ts @@ -94,6 +94,13 @@ export const aggTopHit = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + timeShift: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.timeShift.help', { + defaultMessage: + 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts index 675be2323b93e8..c0eb0c6c241a94 100644 --- a/src/plugins/data/common/search/aggs/types.ts +++ b/src/plugins/data/common/search/aggs/types.ts @@ -132,6 +132,7 @@ export type AggsStart = Assign { + const trimmedVal = val.trim(); + if (trimmedVal === 'previous') { + return 'previous'; + } + const [, amount, unit] = trimmedVal.match(/^(\d+)(\w)$/) || []; + const parsedAmount = Number(amount); + if (Number.isNaN(parsedAmount) || !allowedUnits.includes(unit as AllowedUnit)) { + return 'invalid'; + } + return moment.duration(Number(amount), unit as AllowedUnit); +}; diff --git a/src/plugins/data/common/search/aggs/utils/time_splits.ts b/src/plugins/data/common/search/aggs/utils/time_splits.ts new file mode 100644 index 00000000000000..4ac47efaea3476 --- /dev/null +++ b/src/plugins/data/common/search/aggs/utils/time_splits.ts @@ -0,0 +1,447 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import _, { isArray } from 'lodash'; +import { + Aggregate, + FiltersAggregate, + FiltersBucketItem, + MultiBucketAggregate, +} from '@elastic/elasticsearch/api/types'; + +import { AggGroupNames } from '../agg_groups'; +import { GenericBucket, AggConfigs, getTime, AggConfig } from '../../../../common'; +import { IBucketAggConfig } from '../buckets'; + +/** + * This function will transform an ES response containg a time split (using a filters aggregation before the metrics or date histogram aggregation), + * merging together all branches for the different time ranges into a single response structure which can be tabified into a single table. + * + * If there is just a single time shift, there are no separate branches per time range - in this case only the date histogram keys are shifted by the + * configured amount of time. + * + * To do this, the following steps are taken: + * * Traverse the response tree, tracking the current agg config + * * Once the node which would contain the time split object is found, merge all separate time range buckets into a single layer of buckets of the parent agg + * * Recursively repeat this process for all nested sub-buckets + * + * Example input: + * ``` + * "aggregations" : { + "product" : { + "buckets" : [ + { + "key" : "Product A", + "doc_count" : 512, + "first_year" : { + "doc_count" : 418, + "overall_revenue" : { + "value" : 2163634.0 + } + }, + "time_offset_split" : { + "buckets" : { + "-1y" : { + "doc_count" : 420, + "year" : { + "buckets" : [ + { + "key_as_string" : "2018", + "doc_count" : 81, + "revenue" : { + "value" : 505124.0 + } + }, + { + "key_as_string" : "2019", + "doc_count" : 65, + "revenue" : { + "value" : 363058.0 + } + } + ] + } + }, + "regular" : { + "doc_count" : 418, + "year" : { + "buckets" : [ + { + "key_as_string" : "2019", + "doc_count" : 65, + "revenue" : { + "value" : 363058.0 + } + }, + { + "key_as_string" : "2020", + "doc_count" : 84, + "revenue" : { + "value" : 392924.0 + } + } + ] + } + } + } + } + }, + { + "key" : "Product B", + "doc_count" : 248, + "first_year" : { + "doc_count" : 215, + "overall_revenue" : { + "value" : 1315547.0 + } + }, + "time_offset_split" : { + "buckets" : { + "-1y" : { + "doc_count" : 211, + "year" : { + "buckets" : [ + { + "key_as_string" : "2018", + "key" : 1618963200000, + "doc_count" : 28, + "revenue" : { + "value" : 156543.0 + } + }, + // ... + * ``` + * + * Example output: + * ``` + * "aggregations" : { + "product" : { + "buckets" : [ + { + "key" : "Product A", + "doc_count" : 512, + "first_year" : { + "doc_count" : 418, + "overall_revenue" : { + "value" : 2163634.0 + } + }, + "year" : { + "buckets" : [ + { + "key_as_string" : "2019", + "doc_count" : 81, + "revenue_regular" : { + "value" : 505124.0 + }, + "revenue_-1y" : { + "value" : 302736.0 + } + }, + { + "key_as_string" : "2020", + "doc_count" : 78, + "revenue_regular" : { + "value" : 392924.0 + }, + "revenue_-1y" : { + "value" : 363058.0 + }, + } + // ... + * ``` + * + * + * @param aggConfigs The agg configs instance + * @param aggCursor The root aggregations object from the response which will be mutated in place + */ +export function mergeTimeShifts(aggConfigs: AggConfigs, aggCursor: Record) { + const timeShifts = aggConfigs.getTimeShifts(); + const hasMultipleTimeShifts = Object.keys(timeShifts).length > 1; + const requestAggs = aggConfigs.getRequestAggs(); + const bucketAggs = aggConfigs.aggs.filter( + (agg) => agg.type.type === AggGroupNames.Buckets + ) as IBucketAggConfig[]; + const mergeAggLevel = ( + target: GenericBucket, + source: GenericBucket, + shift: moment.Duration, + aggIndex: number + ) => { + Object.entries(source).forEach(([key, val]) => { + // copy over doc count into special key + if (typeof val === 'number' && key === 'doc_count') { + if (shift.asMilliseconds() === 0) { + target.doc_count = val; + } else { + target[`doc_count_${shift.asMilliseconds()}`] = val; + } + } else if (typeof val !== 'object') { + // other meta keys not of interest + return; + } else { + // a sub-agg + const agg = requestAggs.find((requestAgg) => key.indexOf(requestAgg.id) === 0); + if (agg && agg.type.type === AggGroupNames.Metrics) { + const timeShift = agg.getTimeShift(); + if ( + (timeShift && timeShift.asMilliseconds() === shift.asMilliseconds()) || + (shift.asMilliseconds() === 0 && !timeShift) + ) { + // this is a metric from the current time shift, copy it over + target[key] = source[key]; + } + } else if (agg && agg === bucketAggs[aggIndex]) { + const bucketAgg = agg as IBucketAggConfig; + // expected next bucket sub agg + const subAggregate = val as Aggregate; + const buckets = ('buckets' in subAggregate ? subAggregate.buckets : undefined) as + | GenericBucket[] + | Record + | undefined; + if (!target[key]) { + // sub aggregate only exists in shifted branch, not in base branch - create dummy aggregate + // which will be filled with shifted data + target[key] = { + buckets: isArray(buckets) ? [] : {}, + }; + } + const baseSubAggregate = target[key] as Aggregate; + // only supported bucket formats in agg configs are array of buckets and record of buckets for filters + const baseBuckets = ('buckets' in baseSubAggregate + ? baseSubAggregate.buckets + : undefined) as GenericBucket[] | Record | undefined; + // merge + if (isArray(buckets) && isArray(baseBuckets)) { + const baseBucketMap: Record = {}; + baseBuckets.forEach((bucket) => { + baseBucketMap[String(bucket.key)] = bucket; + }); + buckets.forEach((bucket) => { + const bucketKey = bucketAgg.type.getShiftedKey(bucketAgg, bucket.key, shift); + // if a bucket is missing in the map, create an empty one + if (!baseBucketMap[bucketKey]) { + baseBucketMap[String(bucketKey)] = { + key: bucketKey, + } as GenericBucket; + } + mergeAggLevel(baseBucketMap[bucketKey], bucket, shift, aggIndex + 1); + }); + (baseSubAggregate as MultiBucketAggregate).buckets = Object.values( + baseBucketMap + ).sort((a, b) => bucketAgg.type.orderBuckets(bucketAgg, a, b)); + } else if (baseBuckets && buckets && !isArray(baseBuckets)) { + Object.entries(buckets).forEach(([bucketKey, bucket]) => { + // if a bucket is missing in the base response, create an empty one + if (!baseBuckets[bucketKey]) { + baseBuckets[bucketKey] = {} as GenericBucket; + } + mergeAggLevel(baseBuckets[bucketKey], bucket, shift, aggIndex + 1); + }); + } + } + } + }); + }; + const transformTimeShift = (cursor: Record, aggIndex: number): undefined => { + const shouldSplit = aggConfigs.aggs[aggIndex].type.splitForTimeShift( + aggConfigs.aggs[aggIndex], + aggConfigs + ); + if (shouldSplit) { + // multiple time shifts caused a filters agg in the tree we have to merge + if (hasMultipleTimeShifts && cursor.time_offset_split) { + const timeShiftedBuckets = (cursor.time_offset_split as FiltersAggregate).buckets as Record< + string, + FiltersBucketItem + >; + const subTree = {}; + Object.entries(timeShifts).forEach(([key, shift]) => { + mergeAggLevel( + subTree as GenericBucket, + timeShiftedBuckets[key] as GenericBucket, + shift, + aggIndex + ); + }); + + delete cursor.time_offset_split; + Object.assign(cursor, subTree); + } else { + // otherwise we have to "merge" a single level to shift all keys + const [[, shift]] = Object.entries(timeShifts); + const subTree = {}; + mergeAggLevel(subTree, cursor, shift, aggIndex); + Object.assign(cursor, subTree); + } + return; + } + // recurse deeper into the response object + Object.keys(cursor).forEach((subAggId) => { + const subAgg = cursor[subAggId]; + if (typeof subAgg !== 'object' || !('buckets' in subAgg)) { + return; + } + if (isArray(subAgg.buckets)) { + subAgg.buckets.forEach((bucket) => transformTimeShift(bucket, aggIndex + 1)); + } else { + Object.values(subAgg.buckets).forEach((bucket) => transformTimeShift(bucket, aggIndex + 1)); + } + }); + }; + transformTimeShift(aggCursor, 0); +} + +/** + * Inserts a filters aggregation into the aggregation tree which splits buckets to fetch data for all time ranges + * configured in metric aggregations. + * + * The current agg config can implement `splitForTimeShift` to force insertion of the time split filters aggregation + * before the dsl of the agg config (date histogram and metrics aggregations do this) + * + * Example aggregation tree without time split: + * ``` + * "aggs": { + "product": { + "terms": { + "field": "product", + "size": 3, + "order": { "overall_revenue": "desc" } + }, + "aggs": { + "overall_revenue": { + "sum": { + "field": "sales" + } + }, + "year": { + "date_histogram": { + "field": "timestamp", + "interval": "year" + }, + "aggs": { + "revenue": { + "sum": { + "field": "sales" + } + } + } + // ... + * ``` + * + * Same aggregation tree with inserted time split: + * ``` + * "aggs": { + "product": { + "terms": { + "field": "product", + "size": 3, + "order": { "first_year>overall_revenue": "desc" } + }, + "aggs": { + "first_year": { + "filter": { + "range": { + "timestamp": { + "gte": "2019", + "lte": "2020" + } + } + }, + "aggs": { + "overall_revenue": { + "sum": { + "field": "sales" + } + } + } + }, + "time_offset_split": { + "filters": { + "filters": { + "regular": { + "range": { + "timestamp": { + "gte": "2019", + "lte": "2020" + } + } + }, + "-1y": { + "range": { + "timestamp": { + "gte": "2018", + "lte": "2019" + } + } + } + } + }, + "aggs": { + "year": { + "date_histogram": { + "field": "timestamp", + "interval": "year" + }, + "aggs": { + "revenue": { + "sum": { + "field": "sales" + } + } + } + } + } + } + } + * ``` + */ +export function insertTimeShiftSplit( + aggConfigs: AggConfigs, + config: AggConfig, + timeShifts: Record, + dslLvlCursor: Record +) { + if ('splitForTimeShift' in config.type && !config.type.splitForTimeShift(config, aggConfigs)) { + return dslLvlCursor; + } + if (!aggConfigs.timeFields || aggConfigs.timeFields.length < 1) { + throw new Error('Time shift can only be used with configured time field'); + } + if (!aggConfigs.timeRange) { + throw new Error('Time shift can only be used with configured time range'); + } + const timeRange = aggConfigs.timeRange; + const filters: Record = {}; + const timeField = aggConfigs.timeFields[0]; + Object.entries(timeShifts).forEach(([key, shift]) => { + const timeFilter = getTime(aggConfigs.indexPattern, timeRange, { + fieldName: timeField, + forceNow: aggConfigs.forceNow, + }); + if (timeFilter) { + filters[key] = { + range: { + [timeField]: { + gte: moment(timeFilter.range[timeField].gte).subtract(shift).toISOString(), + lte: moment(timeFilter.range[timeField].lte).subtract(shift).toISOString(), + }, + }, + }; + } + }); + dslLvlCursor.time_offset_split = { + filters: { + filters, + }, + aggs: {}, + }; + + return dslLvlCursor.time_offset_split.aggs; +} diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index 32775464d055f0..4f255cf4c244c6 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -42,6 +42,7 @@ describe('esaggs expression function - public', () => { toDsl: jest.fn().mockReturnValue({ aggs: {} }), onSearchRequestStart: jest.fn(), setTimeFields: jest.fn(), + setForceNow: jest.fn(), } as unknown) as jest.Mocked, filters: undefined, indexPattern: ({ id: 'logstash-*' } as unknown) as jest.Mocked, diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index d152ebf159a8ee..61193c52a5e74b 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -9,15 +9,7 @@ import { i18n } from '@kbn/i18n'; import { Adapters } from 'src/plugins/inspector/common'; -import { - calculateBounds, - Filter, - getTime, - IndexPattern, - isRangeFilter, - Query, - TimeRange, -} from '../../../../common'; +import { calculateBounds, Filter, IndexPattern, Query, TimeRange } from '../../../../common'; import { IAggConfigs } from '../../aggs'; import { ISearchStartSearchSource } from '../../search_source'; @@ -70,8 +62,15 @@ export const handleRequest = async ({ const timeFilterSearchSource = searchSource.createChild({ callParentStartHandlers: true }); const requestSearchSource = timeFilterSearchSource.createChild({ callParentStartHandlers: true }); + // If timeFields have been specified, use the specified ones, otherwise use primary time field of index + // pattern if it's available. + const defaultTimeField = indexPattern?.getTimeField?.(); + const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : []; + const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields; + aggs.setTimeRange(timeRange as TimeRange); - aggs.setTimeFields(timeFields); + aggs.setForceNow(forceNow); + aggs.setTimeFields(allTimeFields); // For now we need to mirror the history of the passed search source, since // the request inspector wouldn't work otherwise. @@ -90,19 +89,11 @@ export const handleRequest = async ({ return aggs.onSearchRequestStart(paramSearchSource, options); }); - // If timeFields have been specified, use the specified ones, otherwise use primary time field of index - // pattern if it's available. - const defaultTimeField = indexPattern?.getTimeField?.(); - const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : []; - const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields; - // If a timeRange has been specified and we had at least one timeField available, create range // filters for that those time fields if (timeRange && allTimeFields.length > 0) { timeFilterSearchSource.setField('filter', () => { - return allTimeFields - .map((fieldName) => getTime(indexPattern, timeRange, { fieldName, forceNow })) - .filter(isRangeFilter); + return aggs.getSearchSourceTimeFilter(forceNow); }); } diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index f35d2d47f1bf47..7633382bb87631 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -75,7 +75,13 @@ import { estypes } from '@elastic/elasticsearch'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patterns'; -import { AggConfigs, ES_SEARCH_STRATEGY, ISearchGeneric, ISearchOptions } from '../..'; +import { + AggConfigs, + ES_SEARCH_STRATEGY, + IEsSearchResponse, + ISearchGeneric, + ISearchOptions, +} from '../..'; import type { ISearchSource, SearchFieldValue, @@ -414,6 +420,15 @@ export class SearchSource { } } + private postFlightTransform(response: IEsSearchResponse) { + const aggs = this.getField('aggs'); + if (aggs instanceof AggConfigs) { + return aggs.postFlightTransform(response); + } else { + return response; + } + } + private async fetchOthers(response: estypes.SearchResponse, options: ISearchOptions) { const aggs = this.getField('aggs'); if (aggs instanceof AggConfigs) { @@ -451,24 +466,26 @@ export class SearchSource { if (isErrorResponse(response)) { obs.error(response); } else if (isPartialResponse(response)) { - obs.next(response); + obs.next(this.postFlightTransform(response)); } else { if (!this.hasPostFlightRequests()) { - obs.next(response); + obs.next(this.postFlightTransform(response)); obs.complete(); } else { // Treat the complete response as partial, then run the postFlightRequests. obs.next({ - ...response, + ...this.postFlightTransform(response), isPartial: true, isRunning: true, }); const sub = from(this.fetchOthers(response.rawResponse, options)).subscribe({ next: (responseWithOther) => { - obs.next({ - ...response, - rawResponse: responseWithOther, - }); + obs.next( + this.postFlightTransform({ + ...response, + rawResponse: responseWithOther!, + }) + ); }, error: (e) => { obs.error(e); diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts index 4a8972d4384c23..a4d9551da75d50 100644 --- a/src/plugins/data/common/search/tabify/tabify.ts +++ b/src/plugins/data/common/search/tabify/tabify.ts @@ -139,7 +139,7 @@ export function tabifyAggResponse( const write = new TabbedAggResponseWriter(aggConfigs, respOpts || {}); const topLevelBucket: AggResponseBucket = { ...esResponse.aggregations, - doc_count: esResponse.hits?.total, + doc_count: esResponse.aggregations?.doc_count || esResponse.hits?.total, }; collectBucket(aggConfigs, write, topLevelBucket, '', 1); diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index be6e489b17290d..9f5c2ef5fad3df 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -7,11 +7,13 @@ import { $Values } from '@kbn/utility-types'; import { Action } from 'history'; import { Adapters as Adapters_2 } from 'src/plugins/inspector/common'; +import { Aggregate } from '@elastic/elasticsearch/api/types'; import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; import { ApplicationStart } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import Boom from '@hapi/boom'; +import { Bucket } from '@elastic/elasticsearch/api/types'; import { ConfigDeprecationProvider } from '@kbn/config'; import { CoreSetup } from 'src/core/public'; import { CoreSetup as CoreSetup_2 } from 'kibana/public'; @@ -46,6 +48,7 @@ import { Href } from 'history'; import { HttpSetup } from 'kibana/public'; import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public'; import { IconType } from '@elastic/eui'; +import { IEsSearchResponse as IEsSearchResponse_2 } from 'src/plugins/data/public'; import { IncomingHttpHeaders } from 'http'; import { InjectedIntl } from '@kbn/i18n/react'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; @@ -74,6 +77,7 @@ import * as PropTypes from 'prop-types'; import { PublicContract } from '@kbn/utility-types'; import { PublicMethodsOf } from '@kbn/utility-types'; import { PublicUiSettingsParams } from 'src/core/server/types'; +import { RangeFilter as RangeFilter_2 } from 'src/plugins/data/public'; import React from 'react'; import * as React_3 from 'react'; import { RecursiveReadonly } from '@kbn/utility-types'; @@ -152,9 +156,13 @@ export class AggConfig { // (undocumented) getTimeRange(): import("../../../public").TimeRange | undefined; // (undocumented) + getTimeShift(): undefined | moment.Duration; + // (undocumented) getValue(bucket: any): any; getValueBucketPath(): string; // (undocumented) + hasTimeShift(): boolean; + // (undocumented) id: string; // (undocumented) isFilterable(): boolean; @@ -245,6 +253,8 @@ export class AggConfigs { addToAggConfigs?: boolean | undefined; }) => T; // (undocumented) + forceNow?: Date; + // (undocumented) getAll(): AggConfig[]; // (undocumented) getRequestAggById(id: string): AggConfig | undefined; @@ -253,6 +263,39 @@ export class AggConfigs { getResponseAggById(id: string): AggConfig | undefined; getResponseAggs(): AggConfig[]; // (undocumented) + getSearchSourceTimeFilter(forceNow?: Date): RangeFilter_2[] | { + meta: { + index: string | undefined; + params: {}; + alias: string; + disabled: boolean; + negate: boolean; + }; + query: { + bool: { + should: { + bool: { + filter: { + range: { + [x: string]: { + gte: string; + lte: string; + }; + }; + }[]; + }; + }[]; + minimum_should_match: number; + }; + }; + }[]; + // (undocumented) + getTimeShiftInterval(): moment.Duration | undefined; + // (undocumented) + getTimeShifts(): Record; + // (undocumented) + hasTimeShifts(): boolean; + // (undocumented) hierarchical?: boolean; // (undocumented) indexPattern: IndexPattern; @@ -260,6 +303,10 @@ export class AggConfigs { // (undocumented) onSearchRequestStart(searchSource: ISearchSource_2, options?: ISearchOptions_2): Promise<[unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown]>; // (undocumented) + postFlightTransform(response: IEsSearchResponse_2): IEsSearchResponse_2; + // (undocumented) + setForceNow(now: Date | undefined): void; + // (undocumented) setTimeFields(timeFields: string[] | undefined): void; // (undocumented) setTimeRange(timeRange: TimeRange): void; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 8ec412e69d4a10..f57ba274881033 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -6,8 +6,10 @@ import { $Values } from '@kbn/utility-types'; import { Adapters } from 'src/plugins/inspector/common'; +import { Aggregate } from '@elastic/elasticsearch/api/types'; import { Assign } from '@kbn/utility-types'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; +import { Bucket } from '@elastic/elasticsearch/api/types'; import { ConfigDeprecationProvider } from '@kbn/config'; import { CoreSetup } from 'src/core/server'; import { CoreSetup as CoreSetup_2 } from 'kibana/server'; @@ -32,6 +34,7 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils'; import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public'; +import { IEsSearchResponse as IEsSearchResponse_2 } from 'src/plugins/data/public'; import { IScopedClusterClient } from 'src/core/server'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; import { ISearchSource } from 'src/plugins/data/public'; @@ -52,6 +55,7 @@ import { Plugin as Plugin_2 } from 'src/core/server'; import { Plugin as Plugin_3 } from 'kibana/server'; import { PluginInitializerContext as PluginInitializerContext_2 } from 'src/core/server'; import { PublicMethodsOf } from '@kbn/utility-types'; +import { RangeFilter } from 'src/plugins/data/public'; import { RecursiveReadonly } from '@kbn/utility-types'; import { RequestAdapter } from 'src/plugins/inspector/common'; import { RequestHandlerContext } from 'src/core/server'; diff --git a/src/plugins/vis_default_editor/public/components/agg_params_helper.ts b/src/plugins/vis_default_editor/public/components/agg_params_helper.ts index fcd97de56fc655..55db880a839322 100644 --- a/src/plugins/vis_default_editor/public/components/agg_params_helper.ts +++ b/src/plugins/vis_default_editor/public/components/agg_params_helper.ts @@ -72,6 +72,9 @@ function getAggParamsToRender({ if (hideCustomLabel && param.name === 'customLabel') { return; } + if (param.name === 'timeShift') { + return; + } // if field param exists, compute allowed fields if (param.type === 'field') { let availableFields: IndexPatternField[] = (param as IFieldParamType).getAvailableFields(agg); diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts new file mode 100644 index 00000000000000..c750602f735bd9 --- /dev/null +++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts @@ -0,0 +1,371 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { Datatable } from 'src/plugins/expressions'; +import { ExpectExpression, expectExpressionProvider } from './helpers'; +import { FtrProviderContext } from '../../../functional/ftr_provider_context'; + +function getCell(esaggsResult: any, row: number, column: number): unknown | undefined { + const columnId = esaggsResult?.columns[column]?.id; + if (!columnId) { + return; + } + return esaggsResult?.rows[row]?.[columnId]; +} + +function checkShift(rows: Datatable['rows'], columns: Datatable['columns'], metricIndex = 1) { + rows.shift(); + rows.pop(); + rows.forEach((_, index) => { + if (index < rows.length - 1) { + expect(getCell({ rows, columns }, index, metricIndex + 1)).to.be( + getCell({ rows, columns }, index + 1, metricIndex) + ); + } + }); +} + +export default function ({ + getService, + updateBaselines, +}: FtrProviderContext & { updateBaselines: boolean }) { + let expectExpression: ExpectExpression; + + describe('esaggs timeshift tests', () => { + before(() => { + expectExpression = expectExpressionProvider({ getService, updateBaselines }); + }); + + const timeRange = { + from: '2015-09-21T00:00:00Z', + to: '2015-09-22T00:00:00Z', + }; + + it('shifts single metric', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggCount id="1" enabled=true schema="metric" timeShift="1d"} + `; + const result = await expectExpression('esaggs_shift_single_metric', expression).getResponse(); + expect(getCell(result, 0, 0)).to.be(4763); + }); + + it('shifts multiple metrics', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggCount id="1" enabled=true schema="metric" timeShift="12h"} + aggs={aggCount id="2" enabled=true schema="metric" timeShift="1d"} + aggs={aggCount id="3" enabled=true schema="metric"} + `; + const result = await expectExpression('esaggs_shift_multi_metric', expression).getResponse(); + expect(getCell(result, 0, 0)).to.be(4629); + expect(getCell(result, 0, 1)).to.be(4763); + expect(getCell(result, 0, 2)).to.be(4618); + }); + + it('shifts single percentile', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggSinglePercentile id="1" enabled=true schema="metric" field="bytes" percentile=95} + aggs={aggSinglePercentile id="2" enabled=true schema="metric" field="bytes" percentile=95 timeShift="1d"} + `; + const result = await expectExpression( + 'esaggs_shift_single_percentile', + expression + ).getResponse(); + // percentile is not stable + expect(getCell(result, 0, 0)).to.be.within(10000, 20000); + expect(getCell(result, 0, 1)).to.be.within(10000, 20000); + }); + + it('shifts multiple percentiles', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggPercentiles id="1" enabled=true schema="metric" field="bytes" percents=5 percents=95} + aggs={aggPercentiles id="2" enabled=true schema="metric" field="bytes" percents=5 percents=95 timeShift="1d"} + `; + const result = await expectExpression( + 'esaggs_shift_multi_percentile', + expression + ).getResponse(); + // percentile is not stable + expect(getCell(result, 0, 0)).to.be.within(100, 1000); + expect(getCell(result, 0, 1)).to.be.within(10000, 20000); + expect(getCell(result, 0, 2)).to.be.within(100, 1000); + expect(getCell(result, 0, 3)).to.be.within(10000, 20000); + }); + + it('shifts date histogram', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggDateHistogram id="1" enabled=true schema="bucket" field="@timestamp" interval="1h"} + aggs={aggAvg id="2" field="bytes" enabled=true schema="metric" timeShift="1h"} + aggs={aggAvg id="3" field="bytes" enabled=true schema="metric"} + `; + const result: Datatable = await expectExpression( + 'esaggs_shift_date_histogram', + expression + ).getResponse(); + expect(result.rows.length).to.be(25); + checkShift(result.rows, result.columns); + }); + + it('shifts filtered metrics', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggDateHistogram id="1" enabled=true schema="bucket" field="@timestamp" interval="1h"} + aggs={aggFilteredMetric + id="2" + customBucket={aggFilter + id="2-filter" + enabled=true + schema="bucket" + filter='{"language":"kuery","query":"geo.src:US"}' + } + customMetric={aggAvg id="3" + field="bytes" + enabled=true + schema="metric" + } + enabled=true + schema="metric" + timeShift="1h" + } + aggs={aggFilteredMetric + id="4" + customBucket={aggFilter + id="4-filter" + enabled=true + schema="bucket" + filter='{"language":"kuery","query":"geo.src:US"}' + } + customMetric={aggAvg id="5" + field="bytes" + enabled=true + schema="metric" + } + enabled=true + schema="metric" + } + `; + const result: Datatable = await expectExpression( + 'esaggs_shift_filtered_metrics', + expression + ).getResponse(); + expect(result.rows.length).to.be(25); + checkShift(result.rows, result.columns); + }); + + it('shifts terms', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggTerms id="1" field="geo.src" size="3" enabled=true schema="bucket" orderAgg={aggCount id="order" enabled=true schema="metric"} otherBucket=true} + aggs={aggAvg id="2" field="bytes" enabled=true schema="metric" timeShift="1d"} + aggs={aggAvg id="3" field="bytes" enabled=true schema="metric"} + `; + const result: Datatable = await expectExpression( + 'esaggs_shift_terms', + expression + ).getResponse(); + expect(result.rows).to.eql([ + { + 'col-0-1': 'CN', + 'col-1-2': 40, + 'col-2-3': 5806.404352806415, + }, + { + 'col-0-1': 'IN', + 'col-1-2': 7901, + 'col-2-3': 5838.315923566879, + }, + { + 'col-0-1': 'US', + 'col-1-2': 7440, + 'col-2-3': 5614.142857142857, + }, + { + 'col-0-1': '__other__', + 'col-1-2': 5766.575645756458, + 'col-2-3': 5742.1265576323985, + }, + ]); + }); + + it('shifts filters', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggFilters id="1" filters='[{"input":{"query":"geo.src:\\"US\\" ","language":"kuery"},"label":""},{"input":{"query":"geo.src: \\"CN\\"","language":"kuery"},"label":""}]'} + aggs={aggFilters id="2" filters='[{"input":{"query":"geo.dest:\\"US\\" ","language":"kuery"},"label":""},{"input":{"query":"geo.dest: \\"CN\\"","language":"kuery"},"label":""}]'} + aggs={aggAvg id="3" field="bytes" enabled=true schema="metric" timeShift="2h"} + aggs={aggAvg id="4" field="bytes" enabled=true schema="metric"} + `; + const result: Datatable = await expectExpression( + 'esaggs_shift_filters', + expression + ).getResponse(); + expect(result.rows).to.eql([ + { + 'col-0-1': 'geo.src:"US" ', + 'col-1-2': 'geo.dest:"US" ', + 'col-2-3': 5956.9, + 'col-3-4': 5956.9, + }, + { + 'col-0-1': 'geo.src:"US" ', + 'col-1-2': 'geo.dest: "CN"', + 'col-2-3': 5127.854838709677, + 'col-3-4': 5085.746031746032, + }, + { + 'col-0-1': 'geo.src: "CN"', + 'col-1-2': 'geo.dest:"US" ', + 'col-2-3': 5648.25, + 'col-3-4': 5643.793650793651, + }, + { + 'col-0-1': 'geo.src: "CN"', + 'col-1-2': 'geo.dest: "CN"', + 'col-2-3': 5842.858823529412, + 'col-3-4': 5842.858823529412, + }, + ]); + }); + + it('shifts histogram', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggHistogram id="1" field="bytes" interval=5000 enabled=true schema="bucket"} + aggs={aggCount id="2" enabled=true schema="metric"} + aggs={aggCount id="3" enabled=true schema="metric" timeShift="6h"} + `; + const result: Datatable = await expectExpression( + 'esaggs_shift_histogram', + expression + ).getResponse(); + expect(result.rows).to.eql([ + { + 'col-0-1': 0, + 'col-1-2': 2020, + 'col-2-3': 2036, + }, + { + 'col-0-1': 5000, + 'col-1-2': 2360, + 'col-2-3': 2358, + }, + { + 'col-0-1': 10000, + 'col-1-2': 126, + 'col-2-3': 127, + }, + { + 'col-0-1': 15000, + 'col-1-2': 112, + 'col-2-3': 108, + }, + ]); + }); + + it('shifts sibling pipeline aggs', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggBucketSum id="1" enabled=true schema="metric" customBucket={aggTerms id="2" enabled="true" schema="bucket" field="geo.src" size="3"} customMetric={aggCount id="4" enabled="true" schema="metric"}} + aggs={aggBucketSum id="5" enabled=true schema="metric" timeShift="1d" customBucket={aggTerms id="6" enabled="true" schema="bucket" field="geo.src" size="3"} customMetric={aggCount id="7" enabled="true" schema="metric"}} + `; + const result: Datatable = await expectExpression( + 'esaggs_shift_sibling_pipeline_aggs', + expression + ).getResponse(); + expect(getCell(result, 0, 0)).to.be(2050); + expect(getCell(result, 0, 1)).to.be(2053); + }); + + it('shifts parent pipeline aggs', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggDateHistogram id="1" enabled=true schema="bucket" field="@timestamp" interval="3h" min_doc_count=0} + aggs={aggMovingAvg id="2" enabled=true schema="metric" metricAgg="custom" window=5 script="MovingFunctions.unweightedAvg(values)" timeShift="3h" customMetric={aggCount id="2-metric" enabled="true" schema="metric"}} + `; + const result: Datatable = await expectExpression( + 'esaggs_shift_parent_pipeline_aggs', + expression + ).getResponse(); + expect(result.rows).to.eql([ + { + 'col-0-1': 1442791800000, + 'col-1-2': null, + }, + { + 'col-0-1': 1442802600000, + 'col-1-2': 30, + }, + { + 'col-0-1': 1442813400000, + 'col-1-2': 30.5, + }, + { + 'col-0-1': 1442824200000, + 'col-1-2': 69.66666666666667, + }, + { + 'col-0-1': 1442835000000, + 'col-1-2': 198.5, + }, + { + 'col-0-1': 1442845800000, + 'col-1-2': 415.6, + }, + { + 'col-0-1': 1442856600000, + 'col-1-2': 702.2, + }, + { + 'col-0-1': 1442867400000, + 'col-1-2': 859.8, + }, + { + 'col-0-1': 1442878200000, + 'col-1-2': 878.4, + }, + ]); + }); + + it('metrics at all levels should work for single shift', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} metricsAtAllLevels=true + aggs={aggCount id="1" enabled=true schema="metric" timeShift="1d"} + `; + const result = await expectExpression('esaggs_shift_single_metric', expression).getResponse(); + expect(getCell(result, 0, 0)).to.be(4763); + }); + + it('metrics at all levels should fail for multiple shifts', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} metricsAtAllLevels=true + aggs={aggCount id="1" enabled=true schema="metric" timeShift="1d"} + aggs={aggCount id="2" enabled=true schema="metric"} + `; + const result = await expectExpression('esaggs_shift_single_metric', expression).getResponse(); + expect(result.type).to.be('error'); + }); + }); +} diff --git a/test/interpreter_functional/test_suites/run_pipeline/index.ts b/test/interpreter_functional/test_suites/run_pipeline/index.ts index c33a87a93b9038..18d20c97be81e9 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/index.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/index.ts @@ -36,5 +36,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid loadTestFile(require.resolve('./tag_cloud')); loadTestFile(require.resolve('./metric')); loadTestFile(require.resolve('./esaggs')); + loadTestFile(require.resolve('./esaggs_timeshift')); }); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 94065f316340cb..45abbf120042d0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -18,6 +18,8 @@ import { EuiButtonEmpty, EuiLink, EuiPageContentBody, + EuiButton, + EuiSpacer, } from '@elastic/eui'; import { CoreStart, ApplicationStart } from 'kibana/public'; import { @@ -80,7 +82,11 @@ export interface WorkspacePanelProps { } interface WorkspaceState { - expressionBuildError?: Array<{ shortMessage: string; longMessage: string }>; + expressionBuildError?: Array<{ + shortMessage: string; + longMessage: string; + fixAction?: { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise }; + }>; expandError: boolean; } @@ -335,6 +341,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ localState={{ ...localState, configurationValidationError, missingRefsErrors }} ExpressionRendererComponent={ExpressionRendererComponent} application={core.application} + activeDatasourceId={activeDatasourceId} /> ); }; @@ -398,6 +405,7 @@ export const VisualizationWrapper = ({ ExpressionRendererComponent, dispatch, application, + activeDatasourceId, }: { expression: string | null | undefined; framePublicAPI: FramePublicAPI; @@ -406,11 +414,16 @@ export const VisualizationWrapper = ({ dispatch: (action: Action) => void; setLocalState: (dispatch: (prevState: WorkspaceState) => WorkspaceState) => void; localState: WorkspaceState & { - configurationValidationError?: Array<{ shortMessage: string; longMessage: string }>; + configurationValidationError?: Array<{ + shortMessage: string; + longMessage: string; + fixAction?: { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise }; + }>; missingRefsErrors?: Array<{ shortMessage: string; longMessage: string }>; }; ExpressionRendererComponent: ReactExpressionRendererType; application: ApplicationStart; + activeDatasourceId: string | null; }) => { const context: ExecutionContextSearch = useMemo( () => ({ @@ -440,6 +453,41 @@ export const VisualizationWrapper = ({ [dispatchLens] ); + function renderFixAction( + validationError: + | { + shortMessage: string; + longMessage: string; + fixAction?: + | { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise } + | undefined; + } + | undefined + ) { + return ( + validationError && + validationError.fixAction && + activeDatasourceId && ( + <> + { + const newState = await validationError.fixAction?.newState(framePublicAPI); + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + datasourceId: activeDatasourceId, + updater: newState, + }); + }} + > + {validationError.fixAction.label} + + + + ) + ); + } + if (localState.configurationValidationError?.length) { let showExtraErrors = null; let showExtraErrorsAction = null; @@ -448,14 +496,17 @@ export const VisualizationWrapper = ({ if (localState.expandError) { showExtraErrors = localState.configurationValidationError .slice(1) - .map(({ longMessage }) => ( -

- {longMessage} -

+ .map((validationError) => ( + <> +

+ {validationError.longMessage} +

+ {renderFixAction(validationError)} + )); } else { showExtraErrorsAction = ( @@ -487,6 +538,7 @@ export const VisualizationWrapper = ({

{localState.configurationValidationError[0].longMessage}

+ {renderFixAction(localState.configurationValidationError?.[0])} {showExtraErrors} @@ -546,6 +598,7 @@ export const VisualizationWrapper = ({ } if (localState.expressionBuildError?.length) { + const firstError = localState.expressionBuildError[0]; return ( @@ -559,7 +612,7 @@ export const VisualizationWrapper = ({ />

-

{localState.expressionBuildError[0].longMessage}

+

{firstError.longMessage}

} iconColor="danger" diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index 85f7601d8fb292..ec12e9e4002039 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -60,9 +60,20 @@ export function WorkspacePanelWrapper({ }, [dispatch, activeVisualization] ); - const warningMessages = - activeVisualization?.getWarningMessages && - activeVisualization.getWarningMessages(visualizationState, framePublicAPI); + const warningMessages: React.ReactNode[] = []; + if (activeVisualization?.getWarningMessages) { + warningMessages.push( + ...(activeVisualization.getWarningMessages(visualizationState, framePublicAPI) || []) + ); + } + Object.entries(datasourceStates).forEach(([datasourceId, datasourceState]) => { + const datasource = datasourceMap[datasourceId]; + if (!datasourceState.isLoading && datasource.getWarningMessages) { + warningMessages.push( + ...(datasource.getWarningMessages(datasourceState.state, framePublicAPI) || []) + ); + } + }); return ( <>
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx index ea5eb14d9c20eb..c8676faad0eea7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx @@ -20,9 +20,7 @@ export function AdvancedOptions(props: { }) { const [popoverOpen, setPopoverOpen] = useState(false); const popoverOptions = props.options.filter((option) => option.showInPopover); - const inlineOptions = props.options - .filter((option) => option.inlineElement) - .map((option) => React.cloneElement(option.inlineElement!, { key: option.dataTestSubj })); + const inlineOptions = props.options.filter((option) => option.inlineElement); return ( <> @@ -74,7 +72,12 @@ export function AdvancedOptions(props: { {inlineOptions.length > 0 && ( <> - {inlineOptions} + {inlineOptions.map((option, index) => ( + <> + {React.cloneElement(option.inlineElement!, { key: option.dataTestSubj })} + {index !== inlineOptions.length - 1 && } + + ))} )} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 7732b53db62fb9..2ae7b9403a46db 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -43,6 +43,7 @@ import { ReferenceEditor } from './reference_editor'; import { setTimeScaling, TimeScaling } from './time_scaling'; import { defaultFilter, Filtering, setFilter } from './filtering'; import { AdvancedOptions } from './advanced_options'; +import { setTimeShift, TimeShift } from './time_shift'; import { useDebouncedValue } from '../../shared_components'; const operationPanels = getOperationDisplay(); @@ -142,6 +143,7 @@ export function DimensionEditor(props: DimensionEditorProps) { }, [fieldByOperation, operationWithoutField]); const [filterByOpenInitially, setFilterByOpenInitally] = useState(false); + const [timeShiftedFocused, setTimeShiftFocused] = useState(false); // Operations are compatible if they match inputs. They are always compatible in // the empty state. Field-based operations are not compatible with field-less operations. @@ -506,6 +508,38 @@ export function DimensionEditor(props: DimensionEditorProps) { /> ) : null, }, + { + title: i18n.translate('xpack.lens.indexPattern.timeShift.label', { + defaultMessage: 'Time shift', + }), + dataTestSubj: 'indexPattern-time-shift-enable', + onClick: () => { + setTimeShiftFocused(true); + setStateWrapper(setTimeShift(columnId, state.layers[layerId], '')); + }, + showInPopover: Boolean( + operationDefinitionMap[selectedColumn.operationType].shiftable && + selectedColumn.timeShift === undefined && + (currentIndexPattern.timeFieldName || + Object.values(state.layers[layerId].columns).some( + (col) => col.operationType === 'date_histogram' + )) + ), + inlineElement: + operationDefinitionMap[selectedColumn.operationType].shiftable && + selectedColumn.timeShift !== undefined ? ( + + ) : null, + }, ]} /> )} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 25cf20e304daf0..03db6141b917f9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -33,6 +33,9 @@ import { OperationMetadata } from '../../types'; import { DateHistogramIndexPatternColumn } from '../operations/definitions/date_histogram'; import { getFieldByNameFactory } from '../pure_helpers'; import { Filtering } from './filtering'; +import { TimeShift } from './time_shift'; +import { DimensionEditor } from './dimension_editor'; +import { AdvancedOptions } from './advanced_options'; jest.mock('../loader'); jest.mock('../query_input', () => ({ @@ -1319,6 +1322,196 @@ describe('IndexPatternDimensionEditorPanel', () => { }); }); + describe('time shift', () => { + function getProps(colOverrides: Partial) { + return { + ...defaultProps, + state: getStateWithColumns({ + datecolumn: { + dataType: 'date', + isBucketed: true, + label: '', + customLabel: true, + operationType: 'date_histogram', + sourceField: 'ts', + params: { + interval: '1d', + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + sourceField: 'Records', + ...colOverrides, + } as IndexPatternColumn, + }), + columnId: 'col2', + }; + } + + it('should not show custom options if time shift is not available', () => { + const props = { + ...defaultProps, + state: getStateWithColumns({ + col2: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + sourceField: 'Records', + } as IndexPatternColumn, + }), + columnId: 'col2', + }; + wrapper = shallow( + + ); + expect( + wrapper + .find(DimensionEditor) + .dive() + .find(AdvancedOptions) + .dive() + .find('[data-test-subj="indexPattern-time-shift-enable"]') + ).toHaveLength(0); + }); + + it('should show custom options if time shift is available', () => { + wrapper = shallow(); + expect( + wrapper + .find(DimensionEditor) + .dive() + .find(AdvancedOptions) + .dive() + .find('[data-test-subj="indexPattern-time-shift-enable"]') + ).toHaveLength(1); + }); + + it('should show current time shift if set', () => { + wrapper = mount(); + expect(wrapper.find(TimeShift).find(EuiComboBox).prop('selectedOptions')[0].value).toEqual( + '1d' + ); + }); + + it('should allow to set time shift initially', () => { + const props = getProps({}); + wrapper = shallow(); + wrapper + .find(DimensionEditor) + .dive() + .find(AdvancedOptions) + .dive() + .find('[data-test-subj="indexPattern-time-shift-enable"]') + .prop('onClick')!({} as MouseEvent); + expect((props.setState as jest.Mock).mock.calls[0][0](props.state)).toEqual({ + ...props.state, + layers: { + first: { + ...props.state.layers.first, + columns: { + ...props.state.layers.first.columns, + col2: expect.objectContaining({ + timeShift: '', + }), + }, + }, + }, + }); + }); + + it('should carry over time shift to other operation if possible', () => { + const props = getProps({ + timeShift: '1d', + sourceField: 'bytes', + operationType: 'sum', + label: 'Sum of bytes per hour', + }); + wrapper = mount(); + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-count incompatible"]') + .simulate('click'); + expect((props.setState as jest.Mock).mock.calls[0][0](props.state)).toEqual({ + ...props.state, + layers: { + first: { + ...props.state.layers.first, + columns: { + ...props.state.layers.first.columns, + col2: expect.objectContaining({ + timeShift: '1d', + }), + }, + }, + }, + }); + }); + + it('should allow to change time shift', () => { + const props = getProps({ + timeShift: '1d', + }); + wrapper = mount(); + wrapper.find(TimeShift).find(EuiComboBox).prop('onCreateOption')!('1h', []); + expect((props.setState as jest.Mock).mock.calls[0][0](props.state)).toEqual({ + ...props.state, + layers: { + first: { + ...props.state.layers.first, + columns: { + ...props.state.layers.first.columns, + col2: expect.objectContaining({ + timeShift: '1h', + }), + }, + }, + }, + }); + }); + + it('should allow to time shift', () => { + const props = getProps({ + timeShift: '1h', + }); + wrapper = mount(); + wrapper + .find('[data-test-subj="indexPattern-time-shift-remove"]') + .find(EuiButtonIcon) + .prop('onClick')!( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + {} as any + ); + expect((props.setState as jest.Mock).mock.calls[0][0](props.state)).toEqual({ + ...props.state, + layers: { + first: { + ...props.state.layers.first, + columns: { + ...props.state.layers.first.columns, + col2: expect.objectContaining({ + timeShift: undefined, + }), + }, + }, + }, + }); + }); + }); + describe('filtering', () => { function getProps(colOverrides: Partial) { return { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx index bf5b64bf3d6152..61e5da5931e88e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx @@ -27,7 +27,13 @@ export function setTimeScaling( const currentColumn = layer.columns[columnId]; const label = currentColumn.customLabel ? currentColumn.label - : adjustTimeScaleLabelSuffix(currentColumn.label, currentColumn.timeScale, timeScale); + : adjustTimeScaleLabelSuffix( + currentColumn.label, + currentColumn.timeScale, + timeScale, + currentColumn.timeShift, + currentColumn.timeShift + ); return { ...layer, columns: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx new file mode 100644 index 00000000000000..0ac02c15b34a50 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx @@ -0,0 +1,394 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon } from '@elastic/eui'; +import { EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiComboBox } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { uniq } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useRef, useState } from 'react'; +import { Query } from 'src/plugins/data/public'; +import { search } from '../../../../../../src/plugins/data/public'; +import { parseTimeShift } from '../../../../../../src/plugins/data/common'; +import { + adjustTimeScaleLabelSuffix, + IndexPatternColumn, + operationDefinitionMap, +} from '../operations'; +import { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from '../types'; +import { IndexPatternDimensionEditorProps } from './dimension_panel'; +import { FramePublicAPI } from '../../types'; + +// to do: get the language from uiSettings +export const defaultFilter: Query = { + query: '', + language: 'kuery', +}; + +export function setTimeShift( + columnId: string, + layer: IndexPatternLayer, + timeShift: string | undefined +) { + const trimmedTimeShift = timeShift?.trim(); + const currentColumn = layer.columns[columnId]; + const label = currentColumn.customLabel + ? currentColumn.label + : adjustTimeScaleLabelSuffix( + currentColumn.label, + currentColumn.timeScale, + currentColumn.timeScale, + currentColumn.timeShift, + trimmedTimeShift + ); + return { + ...layer, + columns: { + ...layer.columns, + [columnId]: { + ...layer.columns[columnId], + label, + timeShift: trimmedTimeShift, + }, + }, + }; +} + +const timeShiftOptions = [ + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.hour', { + defaultMessage: '1 hour (1h)', + }), + value: '1h', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.3hours', { + defaultMessage: '3 hours (3h)', + }), + value: '3h', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.6hours', { + defaultMessage: '6 hours (6h)', + }), + value: '6h', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.12hours', { + defaultMessage: '12 hours (12h)', + }), + value: '12h', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.day', { + defaultMessage: '1 day (1d)', + }), + value: '1d', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.week', { + defaultMessage: '1 week (1w)', + }), + value: '1w', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.month', { + defaultMessage: '1 month (1M)', + }), + value: '1M', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.3months', { + defaultMessage: '3 months (3M)', + }), + value: '3M', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.6months', { + defaultMessage: '6 months (6M)', + }), + value: '6M', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.year', { + defaultMessage: '1 year (1y)', + }), + value: '1y', + }, + { + label: i18n.translate('xpack.lens.indexPattern.timeShift.previous', { + defaultMessage: 'Previous', + }), + value: 'previous', + }, +]; + +export function TimeShift({ + selectedColumn, + columnId, + layer, + updateLayer, + indexPattern, + isFocused, + activeData, + layerId, +}: { + selectedColumn: IndexPatternColumn; + indexPattern: IndexPattern; + columnId: string; + layer: IndexPatternLayer; + updateLayer: (newLayer: IndexPatternLayer) => void; + isFocused: boolean; + activeData: IndexPatternDimensionEditorProps['activeData']; + layerId: string; +}) { + const focusSetRef = useRef(false); + const [localValue, setLocalValue] = useState(selectedColumn.timeShift); + useEffect(() => { + setLocalValue(selectedColumn.timeShift); + }, [selectedColumn.timeShift]); + const selectedOperation = operationDefinitionMap[selectedColumn.operationType]; + if (!selectedOperation.shiftable || selectedColumn.timeShift === undefined) { + return null; + } + + let dateHistogramInterval: null | moment.Duration = null; + const dateHistogramColumn = layer.columnOrder.find( + (colId) => layer.columns[colId].operationType === 'date_histogram' + ); + if (!dateHistogramColumn && !indexPattern.timeFieldName) { + return null; + } + if (dateHistogramColumn && activeData && activeData[layerId] && activeData[layerId]) { + const column = activeData[layerId].columns.find((col) => col.id === dateHistogramColumn); + if (column) { + dateHistogramInterval = search.aggs.parseInterval( + search.aggs.getDateHistogramMetaDataByDatatableColumn(column)?.interval || '' + ); + } + } + + function isValueTooSmall(parsedValue: ReturnType) { + return ( + dateHistogramInterval && + parsedValue && + typeof parsedValue === 'object' && + parsedValue.asMilliseconds() < dateHistogramInterval.asMilliseconds() + ); + } + + function isValueNotMultiple(parsedValue: ReturnType) { + return ( + dateHistogramInterval && + parsedValue && + typeof parsedValue === 'object' && + !Number.isInteger(parsedValue.asMilliseconds() / dateHistogramInterval.asMilliseconds()) + ); + } + + const parsedLocalValue = localValue && parseTimeShift(localValue); + const isLocalValueInvalid = Boolean(parsedLocalValue === 'invalid'); + const localValueTooSmall = parsedLocalValue && isValueTooSmall(parsedLocalValue); + const localValueNotMultiple = parsedLocalValue && isValueNotMultiple(parsedLocalValue); + + function getSelectedOption() { + if (!localValue) return []; + const goodPick = timeShiftOptions.filter(({ value }) => value === localValue); + if (goodPick.length > 0) return goodPick; + return [ + { + value: localValue, + label: localValue, + }, + ]; + } + + return ( +
{ + if (r && isFocused) { + const timeShiftInput = r.querySelector('[data-test-subj="comboBoxSearchInput"]'); + if (!focusSetRef.current && timeShiftInput instanceof HTMLInputElement) { + focusSetRef.current = true; + timeShiftInput.focus(); + } + } + }} + > + + + + { + const parsedValue = parseTimeShift(value); + return ( + parsedValue && !isValueTooSmall(parsedValue) && !isValueNotMultiple(parsedValue) + ); + })} + selectedOptions={getSelectedOption()} + singleSelection={{ asPlainText: true }} + isInvalid={isLocalValueInvalid} + onCreateOption={(val) => { + const parsedVal = parseTimeShift(val); + if (parsedVal !== 'invalid') { + updateLayer(setTimeShift(columnId, layer, val)); + } else { + setLocalValue(val); + } + }} + onChange={(choices) => { + if (choices.length === 0) { + updateLayer(setTimeShift(columnId, layer, '')); + setLocalValue(''); + return; + } + + const choice = choices[0].value as string; + const parsedVal = parseTimeShift(choice); + if (parsedVal !== 'invalid') { + updateLayer(setTimeShift(columnId, layer, choice)); + } else { + setLocalValue(choice); + } + }} + /> + + + { + updateLayer(setTimeShift(columnId, layer, undefined)); + }} + iconType="cross" + /> + + + +
+ ); +} + +export function getTimeShiftWarningMessages( + state: IndexPatternPrivateState, + { activeData }: FramePublicAPI +) { + if (!state) return; + const warningMessages: React.ReactNode[] = []; + Object.entries(state.layers).forEach(([layerId, layer]) => { + let dateHistogramInterval: null | string = null; + const dateHistogramColumn = layer.columnOrder.find( + (colId) => layer.columns[colId].operationType === 'date_histogram' + ); + if (!dateHistogramColumn) { + return; + } + if (dateHistogramColumn && activeData && activeData[layerId]) { + const column = activeData[layerId].columns.find((col) => col.id === dateHistogramColumn); + if (column) { + dateHistogramInterval = + search.aggs.getDateHistogramMetaDataByDatatableColumn(column)?.interval || null; + } + } + if (dateHistogramInterval === null) { + return; + } + const shiftInterval = search.aggs.parseInterval(dateHistogramInterval)!.asMilliseconds(); + let timeShifts: number[] = []; + const timeShiftMap: Record = {}; + Object.entries(layer.columns).forEach(([columnId, column]) => { + if (column.isBucketed) return; + let duration: number = 0; + if (column.timeShift) { + const parsedTimeShift = parseTimeShift(column.timeShift); + if (parsedTimeShift === 'previous' || parsedTimeShift === 'invalid') { + return; + } + duration = parsedTimeShift.asMilliseconds(); + } + timeShifts.push(duration); + if (!timeShiftMap[duration]) { + timeShiftMap[duration] = []; + } + timeShiftMap[duration].push(columnId); + }); + timeShifts = uniq(timeShifts); + + if (timeShifts.length < 2) { + return; + } + + timeShifts.forEach((timeShift) => { + if (timeShift === 0) return; + if (timeShift < shiftInterval) { + timeShiftMap[timeShift].forEach((columnId) => { + warningMessages.push( + {layer.columns[columnId].label}, + interval: {dateHistogramInterval}, + columnTimeShift: {layer.columns[columnId].timeShift}, + }} + /> + ); + }); + } else if (!Number.isInteger(timeShift / shiftInterval)) { + timeShiftMap[timeShift].forEach((columnId) => { + warningMessages.push( + {layer.columns[columnId].label}, + interval: dateHistogramInterval, + columnTimeShift: layer.columns[columnId].timeShift!, + }} + /> + ); + }); + } + }); + }); + return warningMessages; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 81dff1da578094..64b0bdd7ca2a6a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -7,7 +7,7 @@ import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { getIndexPatternDatasource, IndexPatternColumn } from './indexpattern'; -import { DatasourcePublicAPI, Operation, Datasource } from '../types'; +import { DatasourcePublicAPI, Operation, Datasource, FramePublicAPI } from '../types'; import { coreMock } from 'src/core/public/mocks'; import { IndexPatternPersistedState, IndexPatternPrivateState } from './types'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; @@ -18,6 +18,7 @@ import { operationDefinitionMap, getErrorMessages } from './operations'; import { createMockedFullReference } from './operations/mocks'; import { indexPatternFieldEditorPluginMock } from 'src/plugins/index_pattern_field_editor/public/mocks'; import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/public/mocks'; +import React from 'react'; jest.mock('./loader'); jest.mock('../id_generator'); @@ -500,6 +501,43 @@ describe('IndexPattern Data Source', () => { expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); }); + it('should pass time shift parameter to metric agg functions', async () => { + const queryBaseState: IndexPatternBaseState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col2', 'col1'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + timeShift: '1d', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = enrichBaseState(queryBaseState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect((ast.chain[0].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']); + }); + it('should wrap filtered metrics in filtered metric aggregation', async () => { const queryBaseState: IndexPatternBaseState = { currentIndexPatternId: '1', @@ -1267,6 +1305,135 @@ describe('IndexPattern Data Source', () => { }); }); + describe('#getWarningMessages', () => { + it('should return mismatched time shifts', () => { + const state: IndexPatternPrivateState = { + indexPatternRefs: [], + existingFields: {}, + isFirstExistenceFetch: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5', 'col6'], + columns: { + col1: { + operationType: 'date_histogram', + params: { + interval: '12h', + }, + label: '', + dataType: 'date', + isBucketed: true, + sourceField: 'timestamp', + }, + col2: { + operationType: 'count', + label: '', + dataType: 'number', + isBucketed: false, + sourceField: 'records', + }, + col3: { + operationType: 'count', + timeShift: '1h', + label: '', + dataType: 'number', + isBucketed: false, + sourceField: 'records', + }, + col4: { + operationType: 'count', + timeShift: '13h', + label: '', + dataType: 'number', + isBucketed: false, + sourceField: 'records', + }, + col5: { + operationType: 'count', + timeShift: '1w', + label: '', + dataType: 'number', + isBucketed: false, + sourceField: 'records', + }, + col6: { + operationType: 'count', + timeShift: 'previous', + label: '', + dataType: 'number', + isBucketed: false, + sourceField: 'records', + }, + }, + }, + }, + currentIndexPatternId: '1', + }; + const warnings = indexPatternDatasource.getWarningMessages!(state, ({ + activeData: { + first: { + type: 'datatable', + rows: [], + columns: [ + { + id: 'col1', + name: 'col1', + meta: { + type: 'date', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + params: { + used_interval: '12h', + }, + }, + }, + }, + ], + }, + }, + } as unknown) as FramePublicAPI); + expect(warnings!.length).toBe(2); + expect((warnings![0] as React.ReactElement).props.id).toEqual( + 'xpack.lens.indexPattern.timeShiftSmallWarning' + ); + expect((warnings![1] as React.ReactElement).props.id).toEqual( + 'xpack.lens.indexPattern.timeShiftMultipleWarning' + ); + }); + + it('should prepend each error with its layer number on multi-layer chart', () => { + (getErrorMessages as jest.Mock).mockClear(); + (getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']); + const state: IndexPatternPrivateState = { + indexPatternRefs: [], + existingFields: {}, + isFirstExistenceFetch: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }; + expect(indexPatternDatasource.getErrorMessages(state)).toEqual([ + { longMessage: 'Layer 1 error: error 1', shortMessage: '' }, + { longMessage: 'Layer 1 error: error 2', shortMessage: '' }, + ]); + expect(getErrorMessages).toHaveBeenCalledTimes(2); + }); + }); + describe('#updateStateOnCloseDimension', () => { it('should not update when there are no incomplete columns', () => { expect( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 8b60cf134fe6fa..7cb49de15c0665 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -55,6 +55,7 @@ import { deleteColumn, isReferenced } from './operations'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { GeoFieldWorkspacePanel } from '../editor_frame_service/editor_frame/workspace_panel/geo_field_workspace_panel'; import { DraggingIdentifier } from '../drag_drop'; +import { getTimeShiftWarningMessages } from './dimension_panel/time_shift'; export { OperationType, IndexPatternColumn, deleteColumn } from './operations'; @@ -407,13 +408,20 @@ export function getIndexPatternDatasource({ } // Forward the indexpattern as well, as it is required by some operationType checks - const layerErrors = Object.values(state.layers).map((layer) => - (getErrorMessages(layer, state.indexPatterns[layer.indexPatternId]) ?? []).map( - (message) => ({ - shortMessage: '', // Not displayed currently - longMessage: message, - }) - ) + const layerErrors = Object.entries(state.layers).map(([layerId, layer]) => + ( + getErrorMessages( + layer, + state.indexPatterns[layer.indexPatternId], + state, + layerId, + core + ) ?? [] + ).map((message) => ({ + shortMessage: '', // Not displayed currently + longMessage: typeof message === 'string' ? message : message.message, + fixAction: typeof message === 'object' ? message.fixAction : undefined, + })) ); // Single layer case, no need to explain more @@ -449,6 +457,7 @@ export function getIndexPatternDatasource({ }); return messages.length ? messages : undefined; }, + getWarningMessages: getTimeShiftWarningMessages, checkIntegrity: (state) => { const ids = Object.values(state.layers || {}).map(({ indexPatternId }) => indexPatternId); return ids.filter((id) => !state.indexPatterns[id]); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index fc9504f003198c..823ec3eb58a924 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -70,7 +70,8 @@ export const counterRateOperation: OperationDefinition< ref && 'sourceField' in ref ? indexPattern.getFieldByName(ref.sourceField)?.displayName : undefined, - column.timeScale + column.timeScale, + column.timeShift ); }, toExpression: (layer, columnId) => { @@ -84,7 +85,8 @@ export const counterRateOperation: OperationDefinition< metric && 'sourceField' in metric ? indexPattern.getFieldByName(metric.sourceField)?.displayName : undefined, - timeScale + timeScale, + previousColumn?.timeShift ), dataType: 'number', operationType: 'counter_rate', @@ -92,6 +94,7 @@ export const counterRateOperation: OperationDefinition< scale: 'ratio', references: referenceIds, timeScale, + timeShift: previousColumn?.timeShift, filter: getFilter(previousColumn, columnParams), params: getFormatFromPreviousColumn(previousColumn), }; @@ -118,4 +121,5 @@ export const counterRateOperation: OperationDefinition< }, timeScalingMode: 'mandatory', filterable: true, + shiftable: true, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx index 2adb9a1376f606..c4f01e27be886f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx @@ -13,11 +13,12 @@ import { getErrorsForDateReference, dateBasedOperationToExpression, hasDateField, + buildLabelFunction, } from './utils'; import { OperationDefinition } from '..'; import { getFormatFromPreviousColumn, getFilter } from '../helpers'; -const ofName = (name?: string) => { +const ofName = buildLabelFunction((name?: string) => { return i18n.translate('xpack.lens.indexPattern.cumulativeSumOf', { defaultMessage: 'Cumulative sum of {name}', values: { @@ -28,7 +29,7 @@ const ofName = (name?: string) => { }), }, }); -}; +}); export type CumulativeSumIndexPatternColumn = FormattedIndexPatternColumn & ReferenceBasedIndexPatternColumn & { @@ -67,7 +68,9 @@ export const cumulativeSumOperation: OperationDefinition< return ofName( ref && 'sourceField' in ref ? indexPattern.getFieldByName(ref.sourceField)?.displayName - : undefined + : undefined, + undefined, + column.timeShift ); }, toExpression: (layer, columnId) => { @@ -79,12 +82,15 @@ export const cumulativeSumOperation: OperationDefinition< label: ofName( ref && 'sourceField' in ref ? indexPattern.getFieldByName(ref.sourceField)?.displayName - : undefined + : undefined, + undefined, + previousColumn?.timeShift ), dataType: 'number', operationType: 'cumulative_sum', isBucketed: false, scale: 'ratio', + timeShift: previousColumn?.timeShift, filter: getFilter(previousColumn, columnParams), references: referenceIds, params: getFormatFromPreviousColumn(previousColumn), @@ -111,4 +117,5 @@ export const cumulativeSumOperation: OperationDefinition< )?.join(', '); }, filterable: true, + shiftable: true, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index 06555a9b41c2ff..7c48b5742b8dbb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -66,7 +66,7 @@ export const derivativeOperation: OperationDefinition< } }, getDefaultLabel: (column, indexPattern, columns) => { - return ofName(columns[column.references[0]]?.label, column.timeScale); + return ofName(columns[column.references[0]]?.label, column.timeScale, column.timeShift); }, toExpression: (layer, columnId) => { return dateBasedOperationToExpression(layer, columnId, 'derivative'); @@ -74,7 +74,7 @@ export const derivativeOperation: OperationDefinition< buildColumn: ({ referenceIds, previousColumn, layer }, columnParams) => { const ref = layer.columns[referenceIds[0]]; return { - label: ofName(ref?.label, previousColumn?.timeScale), + label: ofName(ref?.label, previousColumn?.timeScale, previousColumn?.timeShift), dataType: 'number', operationType: OPERATION_NAME, isBucketed: false, @@ -82,6 +82,7 @@ export const derivativeOperation: OperationDefinition< references: referenceIds, timeScale: previousColumn?.timeScale, filter: getFilter(previousColumn, columnParams), + timeShift: previousColumn?.timeShift, params: getFormatFromPreviousColumn(previousColumn), }; }, @@ -108,4 +109,5 @@ export const derivativeOperation: OperationDefinition< }, timeScalingMode: 'optional', filterable: true, + shiftable: true, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 0e74ef6b85c804..a3d0241d4887e9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -76,7 +76,7 @@ export const movingAverageOperation: OperationDefinition< } }, getDefaultLabel: (column, indexPattern, columns) => { - return ofName(columns[column.references[0]]?.label, column.timeScale); + return ofName(columns[column.references[0]]?.label, column.timeScale, column.timeShift); }, toExpression: (layer, columnId) => { return dateBasedOperationToExpression(layer, columnId, 'moving_average', { @@ -90,7 +90,7 @@ export const movingAverageOperation: OperationDefinition< const metric = layer.columns[referenceIds[0]]; const { window = WINDOW_DEFAULT_VALUE } = columnParams; return { - label: ofName(metric?.label, previousColumn?.timeScale), + label: ofName(metric?.label, previousColumn?.timeScale, previousColumn?.timeShift), dataType: 'number', operationType: 'moving_average', isBucketed: false, @@ -98,6 +98,7 @@ export const movingAverageOperation: OperationDefinition< references: referenceIds, timeScale: previousColumn?.timeScale, filter: getFilter(previousColumn, columnParams), + timeShift: previousColumn?.timeShift, params: { window, ...getFormatFromPreviousColumn(previousColumn), @@ -129,6 +130,7 @@ export const movingAverageOperation: OperationDefinition< }, timeScalingMode: 'optional', filterable: true, + shiftable: true, }; function MovingAverageParamEditor({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts index 59dbf74c11480c..1f4f097c6a7fb5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts @@ -16,10 +16,11 @@ import { operationDefinitionMap } from '..'; export const buildLabelFunction = (ofName: (name?: string) => string) => ( name?: string, - timeScale?: TimeScaleUnit + timeScale?: TimeScaleUnit, + timeShift?: string ) => { const rawLabel = ofName(name); - return adjustTimeScaleLabelSuffix(rawLabel, undefined, timeScale); + return adjustTimeScaleLabelSuffix(rawLabel, undefined, timeScale, undefined, timeShift); }; /** diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index e77357a6f441a7..1911af0a6f679b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -17,6 +17,7 @@ import { getSafeName, getFilter, } from './helpers'; +import { adjustTimeScaleLabelSuffix } from '../time_scale_utils'; const supportedTypes = new Set([ 'string', @@ -33,13 +34,19 @@ const SCALE = 'ratio'; const OPERATION_TYPE = 'unique_count'; const IS_BUCKETED = false; -function ofName(name: string) { - return i18n.translate('xpack.lens.indexPattern.cardinalityOf', { - defaultMessage: 'Unique count of {name}', - values: { - name, - }, - }); +function ofName(name: string, timeShift: string | undefined) { + return adjustTimeScaleLabelSuffix( + i18n.translate('xpack.lens.indexPattern.cardinalityOf', { + defaultMessage: 'Unique count of {name}', + values: { + name, + }, + }), + undefined, + undefined, + undefined, + timeShift + ); } export interface CardinalityIndexPatternColumn @@ -76,21 +83,19 @@ export const cardinalityOperation: OperationDefinition ofName(getSafeName(column.sourceField, indexPattern)), + shiftable: true, + getDefaultLabel: (column, indexPattern) => + ofName(getSafeName(column.sourceField, indexPattern), column.timeShift), buildColumn({ field, previousColumn }, columnParams) { return { - label: ofName(field.displayName), + label: ofName(field.displayName, previousColumn?.timeShift), dataType: 'number', operationType: OPERATION_TYPE, scale: SCALE, sourceField: field.name, isBucketed: IS_BUCKETED, filter: getFilter(previousColumn, columnParams), + timeShift: previousColumn?.timeShift, params: getFormatFromPreviousColumn(previousColumn), }; }, @@ -100,12 +105,14 @@ export const cardinalityOperation: OperationDefinition { return { ...oldColumn, - label: ofName(field.displayName), + label: ofName(field.displayName, oldColumn.timeShift), sourceField: field.name, }; }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts index aa6b8675333c56..ae606a5851665e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts @@ -16,6 +16,7 @@ export interface BaseIndexPatternColumn extends Operation { customLabel?: boolean; timeScale?: TimeScaleUnit; filter?: Query; + timeShift?: string; } // Formatting can optionally be added to any column diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx index fd474ea04a165b..7bf463a2095ad4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx @@ -38,7 +38,13 @@ export const countOperation: OperationDefinition { return { ...oldColumn, - label: adjustTimeScaleLabelSuffix(field.displayName, undefined, oldColumn.timeScale), + label: adjustTimeScaleLabelSuffix( + field.displayName, + undefined, + oldColumn.timeScale, + undefined, + oldColumn.timeShift + ), sourceField: field.name, }; }, @@ -51,10 +57,23 @@ export const countOperation: OperationDefinition adjustTimeScaleLabelSuffix(countLabel, undefined, column.timeScale), + getDefaultLabel: (column) => + adjustTimeScaleLabelSuffix( + countLabel, + undefined, + column.timeScale, + undefined, + column.timeShift + ), buildColumn({ field, previousColumn }, columnParams) { return { - label: adjustTimeScaleLabelSuffix(countLabel, undefined, previousColumn?.timeScale), + label: adjustTimeScaleLabelSuffix( + countLabel, + undefined, + previousColumn?.timeScale, + undefined, + previousColumn?.timeShift + ), dataType: 'number', operationType: 'count', isBucketed: false, @@ -62,6 +81,7 @@ export const countOperation: OperationDefinition { @@ -89,4 +111,5 @@ export const countOperation: OperationDefinition col.timeShift && col.timeShift !== '' + ); + if (!usesTimeShift) { + return undefined; + } + const dateHistograms = layer.columnOrder.filter( + (colId) => layer.columns[colId].operationType === 'date_histogram' + ); + if (dateHistograms.length < 2) { + return undefined; + } + return i18n.translate('xpack.lens.indexPattern.multipleDateHistogramsError', { + defaultMessage: + '"{dimensionLabel}" is not the only date histogram. When using time shifts, make sure to only use one date histogram.', + values: { + dimensionLabel: layer.columns[columnId].label, + }, + }); +} + export const dateHistogramOperation: OperationDefinition< DateHistogramIndexPatternColumn, 'field' @@ -60,7 +83,13 @@ export const dateHistogramOperation: OperationDefinition< priority: 5, // Highest priority level used operationParams: [{ name: 'interval', type: 'string', required: false }], getErrorMessage: (layer, columnId, indexPattern) => - getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), + [ + ...(getInvalidFieldMessage( + layer.columns[columnId] as FieldBasedIndexPatternColumn, + indexPattern + ) || []), + getMultipleDateHistogramsErrorMessage(layer, columnId) || '', + ].filter(Boolean), getHelpMessage: (props) => , getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( @@ -150,7 +179,15 @@ export const dateHistogramOperation: OperationDefinition< extended_bounds: JSON.stringify({}), }).toAst(); }, - paramEditor: ({ layer, columnId, currentColumn, updateLayer, dateRange, data, indexPattern }) => { + paramEditor: function ParamEditor({ + layer, + columnId, + currentColumn, + updateLayer, + dateRange, + data, + indexPattern, + }: ParamEditorProps) { const field = currentColumn && indexPattern.getFieldByName(currentColumn.sourceField); const intervalIsRestricted = field!.aggregationRestrictions && field!.aggregationRestrictions.date_histogram; @@ -225,10 +262,11 @@ export const dateHistogramOperation: OperationDefinition< disabled={calendarOnlyIntervals.has(interval.unit)} isInvalid={!isValid} onChange={(e) => { - setInterval({ + const newInterval = { ...interval, value: e.target.value, - }); + }; + setInterval(newInterval); }} /> @@ -238,10 +276,11 @@ export const dateHistogramOperation: OperationDefinition< data-test-subj="lensDateHistogramUnit" value={interval.unit} onChange={(e) => { - setInterval({ + const newInterval = { ...interval, unit: e.target.value, - }); + }; + setInterval(newInterval); }} isInvalid={!isValid} options={[ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index cbc83db7e5f376..164415c1a1f6f3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreStart } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { termsOperation, TermsIndexPatternColumn } from './terms'; import { filtersOperation, FiltersIndexPatternColumn } from './filters'; @@ -42,13 +42,14 @@ import { FormulaIndexPatternColumn, } from './formula'; import { lastValueOperation, LastValueIndexPatternColumn } from './last_value'; -import { OperationMetadata } from '../../../types'; +import { FramePublicAPI, OperationMetadata } from '../../../types'; import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; import { IndexPattern, IndexPatternField, IndexPatternLayer } from '../../types'; import { DateRange } from '../../../../common'; import { ExpressionAstFunction } from '../../../../../../../src/plugins/expressions/public'; import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { RangeIndexPatternColumn, rangeOperation } from './ranges'; +import { IndexPatternDimensionEditorProps } from '../../dimension_panel'; /** * A union type of all available column types. If a column is of an unknown type somewhere @@ -160,6 +161,7 @@ export interface ParamEditorProps { http: HttpSetup; dateRange: DateRange; data: DataPublicPluginStart; + activeData?: IndexPatternDimensionEditorProps['activeData']; operationDefinitionMap: Record; } @@ -240,7 +242,22 @@ interface BaseOperationDefinitionProps { columnId: string, indexPattern: IndexPattern, operationDefinitionMap?: Record - ) => string[] | undefined; + ) => + | Array< + | string + | { + message: string; + fixAction?: { + label: string; + newState: ( + core: CoreStart, + frame: FramePublicAPI, + layerId: string + ) => Promise; + }; + } + > + | undefined; /* * Flag whether this operation can be scaled by time unit if a date histogram is available. @@ -255,6 +272,7 @@ interface BaseOperationDefinitionProps { * autocomplete. */ filterable?: boolean; + shiftable?: boolean; getHelpMessage?: (props: HelpProps) => React.ReactNode; /* @@ -366,12 +384,27 @@ interface FieldBasedOperationDefinition { * - Requires a date histogram operation somewhere before it in order * - Missing references */ - getErrorMessage: ( + getErrorMessage?: ( layer: IndexPatternLayer, columnId: string, indexPattern: IndexPattern, operationDefinitionMap?: Record - ) => string[] | undefined; + ) => + | Array< + | string + | { + message: string; + fixAction?: { + label: string; + newState: ( + core: CoreStart, + frame: FramePublicAPI, + layerId: string + ) => Promise; + }; + } + > + | undefined; } export interface RequiredReference { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx index 4632d262c441d4..bde80accfbc676 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx @@ -21,14 +21,21 @@ import { getSafeName, getFilter, } from './helpers'; +import { adjustTimeScaleLabelSuffix } from '../time_scale_utils'; -function ofName(name: string) { - return i18n.translate('xpack.lens.indexPattern.lastValueOf', { - defaultMessage: 'Last value of {name}', - values: { - name, - }, - }); +function ofName(name: string, timeShift: string | undefined) { + return adjustTimeScaleLabelSuffix( + i18n.translate('xpack.lens.indexPattern.lastValueOf', { + defaultMessage: 'Last value of {name}', + values: { + name, + }, + }), + undefined, + undefined, + undefined, + timeShift + ); } const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']); @@ -96,7 +103,8 @@ export const lastValueOperation: OperationDefinition ofName(getSafeName(column.sourceField, indexPattern)), + getDefaultLabel: (column, indexPattern) => + ofName(getSafeName(column.sourceField, indexPattern), column.timeShift), input: 'field', onFieldChange: (oldColumn, field) => { const newParams = { ...oldColumn.params }; @@ -107,7 +115,7 @@ export const lastValueOperation: OperationDefinition { return buildExpressionFunction('aggTopHit', { id: columnId, @@ -184,6 +194,8 @@ export const lastValueOperation: OperationDefinition>({ }) { const labelLookup = (name: string, column?: BaseIndexPatternColumn) => { const label = ofName(name); - if (!optionalTimeScaling) { - return label; - } - return adjustTimeScaleLabelSuffix(label, undefined, column?.timeScale); + return adjustTimeScaleLabelSuffix( + label, + undefined, + optionalTimeScaling ? column?.timeScale : undefined, + undefined, + column?.timeShift + ); }; return { @@ -104,6 +107,7 @@ function buildMetricOperation>({ scale: 'ratio', timeScale: optionalTimeScaling ? previousColumn?.timeScale : undefined, filter: getFilter(previousColumn, columnParams), + timeShift: previousColumn?.timeShift, params: getFormatFromPreviousColumn(previousColumn), } as T; }, @@ -120,11 +124,14 @@ function buildMetricOperation>({ enabled: true, schema: 'metric', field: column.sourceField, + // time shift is added to wrapping aggFilteredMetric if filter is set + timeShift: column.filter ? undefined : column.timeShift, }).toAst(); }, getErrorMessage: (layer, columnId, indexPattern) => getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), filterable: true, + shiftable: true, } as OperationDefinition; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx index 4c09ae4ed8c47b..aa8f951d46b4f2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx @@ -19,6 +19,7 @@ import { getFilter, } from './helpers'; import { FieldBasedIndexPatternColumn } from './column_types'; +import { adjustTimeScaleLabelSuffix } from '../time_scale_utils'; import { useDebounceWithOptions } from '../../../shared_components'; export interface PercentileIndexPatternColumn extends FieldBasedIndexPatternColumn { @@ -34,12 +35,18 @@ export interface PercentileIndexPatternColumn extends FieldBasedIndexPatternColu }; } -function ofName(name: string, percentile: number) { - return i18n.translate('xpack.lens.indexPattern.percentileOf', { - defaultMessage: - '{percentile, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} percentile of {name}', - values: { name, percentile }, - }); +function ofName(name: string, percentile: number, timeShift: string | undefined) { + return adjustTimeScaleLabelSuffix( + i18n.translate('xpack.lens.indexPattern.percentileOf', { + defaultMessage: + '{percentile, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} percentile of {name}', + values: { name, percentile }, + }), + undefined, + undefined, + undefined, + timeShift + ); } const DEFAULT_PERCENTILE_VALUE = 95; @@ -54,6 +61,7 @@ export const percentileOperation: OperationDefinition { if (supportedFieldTypes.includes(fieldType) && aggregatable && !aggregationRestrictions) { return { @@ -74,7 +82,11 @@ export const percentileOperation: OperationDefinition - ofName(getSafeName(column.sourceField, indexPattern), column.params.percentile), + ofName( + getSafeName(column.sourceField, indexPattern), + column.params.percentile, + column.timeShift + ), buildColumn: ({ field, previousColumn, indexPattern }, columnParams) => { const existingPercentileParam = previousColumn?.operationType === 'percentile' && @@ -84,13 +96,18 @@ export const percentileOperation: OperationDefinition { return { ...oldColumn, - label: ofName(field.displayName, oldColumn.params.percentile), + label: ofName(field.displayName, oldColumn.params.percentile, oldColumn.timeShift), sourceField: field.name, }; }, @@ -113,6 +130,8 @@ export const percentileOperation: OperationDefinition operationDefinitionMap[col.operationType].shiftable) + .map((col) => col.timeShift || '') + ).length > 1; + if (!hasMultipleShifts) { + return undefined; + } + return { + message: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShifts', { + defaultMessage: + 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', + }), + fixAction: { + label: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel', { + defaultMessage: 'Use filters', + }), + newState: async (core: CoreStart, frame: FramePublicAPI, layerId: string) => { + const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn; + const fieldName = currentColumn.sourceField; + const activeDataFieldNameMatch = + frame.activeData?.[layerId].columns.find(({ id }) => id === columnId)?.meta.field === + fieldName; + let currentTerms = uniq( + frame.activeData?.[layerId].rows + .map((row) => row[columnId] as string) + .filter((term) => typeof term === 'string' && term !== '__other__') || [] + ); + if (!activeDataFieldNameMatch || currentTerms.length === 0) { + const response: FieldStatsResponse = await core.http.post( + `/api/lens/index_stats/${indexPattern.id}/field`, + { + body: JSON.stringify({ + fieldName, + dslQuery: esQuery.buildEsQuery( + indexPattern as IIndexPattern, + frame.query, + frame.filters, + esQuery.getEsQueryConfig(core.uiSettings) + ), + fromDate: frame.dateRange.fromDate, + toDate: frame.dateRange.toDate, + size: currentColumn.params.size, + }), + } + ); + currentTerms = response.topValues?.buckets.map(({ key }) => String(key)) || []; + } + return { + ...layer, + columns: { + ...layer.columns, + [columnId]: { + label: i18n.translate('xpack.lens.indexPattern.pinnedTopValuesLabel', { + defaultMessage: 'Filters of {field}', + values: { + field: fieldName, + }, + }), + customLabel: true, + isBucketed: layer.columns[columnId].isBucketed, + dataType: 'string', + operationType: 'filters', + params: { + filters: + currentTerms.length > 0 + ? currentTerms.map((term) => ({ + input: { + query: `${fieldName}: "${term}"`, + language: 'kuery', + }, + label: term, + })) + : [ + { + input: { + query: '*', + language: 'kuery', + }, + label: defaultLabel, + }, + ], + }, + } as FiltersIndexPatternColumn, + }, + }; + }, + }, + }; +} + const DEFAULT_SIZE = 3; const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']); @@ -90,7 +195,13 @@ export const termsOperation: OperationDefinition - getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), + [ + ...(getInvalidFieldMessage( + layer.columns[columnId] as FieldBasedIndexPatternColumn, + indexPattern + ) || []), + getDisallowedTermsMessage(layer, columnId, indexPattern) || '', + ].filter(Boolean), isTransferable: (column, newIndexPattern) => { const newField = newIndexPattern.getFieldByName(column.sourceField); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index aab957c8ecebe4..b272d1703377ce 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -9,7 +9,12 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow, mount } from 'enzyme'; import { EuiFieldNumber, EuiSelect, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; -import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import type { + IUiSettingsClient, + SavedObjectsClientContract, + HttpSetup, + CoreStart, +} from 'kibana/public'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; import { createMockedIndexPattern } from '../../../mocks'; @@ -17,6 +22,7 @@ import { ValuesInput } from './values_input'; import type { TermsIndexPatternColumn } from '.'; import { termsOperation } from '../index'; import { IndexPattern, IndexPatternLayer } from '../../../types'; +import { FramePublicAPI } from '../../../../types'; const uiSettingsMock = {} as IUiSettingsClient; @@ -986,8 +992,8 @@ describe('terms', () => { indexPatternId: '', }; }); - it('returns undefined if sourceField exists in index pattern', () => { - expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual(undefined); + it('returns empty array', () => { + expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([]); }); it('returns error message if the sourceField does not exist in index pattern', () => { layer = { @@ -1003,5 +1009,102 @@ describe('terms', () => { 'Field notExisting was not found', ]); }); + + describe('time shift error', () => { + beforeEach(() => { + layer = { + ...layer, + columnOrder: ['col1', 'col2', 'col3'], + columns: { + ...layer.columns, + col2: { + dataType: 'number', + isBucketed: false, + operationType: 'count', + label: 'Count', + sourceField: 'document', + }, + col3: { + dataType: 'number', + isBucketed: false, + operationType: 'count', + label: 'Count', + sourceField: 'document', + timeShift: '1d', + }, + }, + }; + }); + it('returns error message if two time shifts are used together with terms', () => { + expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([ + expect.objectContaining({ + message: + 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', + }), + ]); + }); + it('returns fix action which calls field information endpoint and creates a pinned top values', async () => { + const errorMessage = termsOperation.getErrorMessage!(layer, 'col1', indexPattern)![0]; + const fixAction = (typeof errorMessage === 'object' + ? errorMessage.fixAction!.newState + : undefined)!; + const coreMock = ({ + uiSettings: { + get: () => undefined, + }, + http: { + post: jest.fn(() => + Promise.resolve({ + topValues: { + buckets: [ + { + key: 'A', + }, + { + key: 'B', + }, + ], + }, + }) + ), + }, + } as unknown) as CoreStart; + const newLayer = await fixAction( + coreMock, + ({ + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + } as unknown) as FramePublicAPI, + 'first' + ); + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { + input: { + language: 'kuery', + query: 'bytes: "A"', + }, + label: 'A', + }, + { + input: { + language: 'kuery', + query: 'bytes: "B"', + }, + label: 'B', + }, + ], + }, + }) + ); + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 4dd56d2de1144a..a53a6e00810b89 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -2589,7 +2589,10 @@ describe('state_helpers', () => { col1: { operationType: 'average' }, }, }, - indexPattern + indexPattern, + {}, + '1', + {} ); expect(mock).toHaveBeenCalled(); expect(errors).toHaveLength(1); @@ -2608,7 +2611,10 @@ describe('state_helpers', () => { { operationType: 'testReference', references: [] }, }, }, - indexPattern + indexPattern, + {}, + '1', + {} ); expect(mock).toHaveBeenCalled(); expect(errors).toHaveLength(1); @@ -2637,7 +2643,10 @@ describe('state_helpers', () => { col1: { operationType: 'testIncompleteReference' }, }, }, - indexPattern + indexPattern, + {}, + '1', + {} ); expect(savedRef).toHaveBeenCalled(); expect(incompleteRef).not.toHaveBeenCalled(); @@ -2659,7 +2668,10 @@ describe('state_helpers', () => { { operationType: 'testReference', references: [] }, }, }, - indexPattern + indexPattern, + {}, + '1', + {} ); expect(mock).toHaveBeenCalledWith( { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 92452a11e94c16..b42cdbd24e656b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -6,8 +6,12 @@ */ import { partition, mapValues, pickBy } from 'lodash'; -import { getSortScoreByPriority } from './operations'; -import type { OperationMetadata, VisualizationDimensionGroupConfig } from '../../types'; +import { CoreStart } from 'kibana/public'; +import type { + FramePublicAPI, + OperationMetadata, + VisualizationDimensionGroupConfig, +} from '../../types'; import { operationDefinitionMap, operationDefinitions, @@ -15,7 +19,13 @@ import { IndexPatternColumn, RequiredReference, } from './definitions'; -import type { IndexPattern, IndexPatternField, IndexPatternLayer } from '../types'; +import type { + IndexPattern, + IndexPatternField, + IndexPatternLayer, + IndexPatternPrivateState, +} from '../types'; +import { getSortScoreByPriority } from './operations'; import { generateId } from '../../id_generator'; import { ReferenceBasedIndexPatternColumn } from './definitions/column_types'; import { FormulaIndexPatternColumn, regenerateLayerFromAst } from './definitions/formula'; @@ -674,6 +684,7 @@ function applyReferenceTransition({ // drop the filter for the referenced column because the wrapping operation // is filterable as well and will handle it one level higher. filter: operationDefinition.filterable ? undefined : previousColumn.filter, + timeShift: operationDefinition.shiftable ? undefined : previousColumn.timeShift, }, }, }; @@ -1135,20 +1146,65 @@ export function updateLayerIndexPattern( * - All columns have complete references * - All column references are valid * - All prerequisites are met + * - If timeshift is used, terms go before date histogram + * - If timeshift is used, only a single date histogram can be used */ export function getErrorMessages( layer: IndexPatternLayer, - indexPattern: IndexPattern -): string[] | undefined { - const errors: string[] = Object.entries(layer.columns) + indexPattern: IndexPattern, + state: IndexPatternPrivateState, + layerId: string, + core: CoreStart +): + | Array< + | string + | { + message: string; + fixAction?: { + label: string; + newState: (frame: FramePublicAPI) => Promise; + }; + } + > + | undefined { + const errors = Object.entries(layer.columns) .flatMap(([columnId, column]) => { const def = operationDefinitionMap[column.operationType]; if (def.getErrorMessage) { return def.getErrorMessage(layer, columnId, indexPattern, operationDefinitionMap); } }) + .map((errorMessage) => { + if (typeof errorMessage !== 'object') { + return errorMessage; + } + return { + ...errorMessage, + fixAction: errorMessage.fixAction + ? { + ...errorMessage.fixAction, + newState: async (frame: FramePublicAPI) => ({ + ...state, + layers: { + ...state.layers, + [layerId]: await errorMessage.fixAction!.newState(core, frame, layerId), + }, + }), + } + : undefined, + }; + }) // remove the undefined values - .filter((v: string | undefined): v is string => v != null); + .filter((v) => v != null) as Array< + | string + | { + message: string; + fixAction?: { + label: string; + newState: (framePublicAPI: FramePublicAPI) => Promise; + }; + } + >; return errors.length ? errors : undefined; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts index d53940aa585fd3..152fcaa457c3bc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts @@ -15,29 +15,84 @@ export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit; describe('time scale utils', () => { describe('adjustTimeScaleLabelSuffix', () => { it('should should remove existing suffix', () => { - expect(adjustTimeScaleLabelSuffix('abc per second', 's', undefined)).toEqual('abc'); - expect(adjustTimeScaleLabelSuffix('abc per hour', 'h', undefined)).toEqual('abc'); + expect( + adjustTimeScaleLabelSuffix('abc per second', 's', undefined, undefined, undefined) + ).toEqual('abc'); + expect( + adjustTimeScaleLabelSuffix('abc per hour', 'h', undefined, undefined, undefined) + ).toEqual('abc'); + expect(adjustTimeScaleLabelSuffix('abc -3d', undefined, undefined, '3d', undefined)).toEqual( + 'abc' + ); + expect( + adjustTimeScaleLabelSuffix('abc per hour -3d', 'h', undefined, '3d', undefined) + ).toEqual('abc'); }); it('should add suffix', () => { - expect(adjustTimeScaleLabelSuffix('abc', undefined, 's')).toEqual('abc per second'); - expect(adjustTimeScaleLabelSuffix('abc', undefined, 'd')).toEqual('abc per day'); + expect(adjustTimeScaleLabelSuffix('abc', undefined, 's', undefined, undefined)).toEqual( + 'abc per second' + ); + expect(adjustTimeScaleLabelSuffix('abc', undefined, 'd', undefined, undefined)).toEqual( + 'abc per day' + ); + expect(adjustTimeScaleLabelSuffix('abc', undefined, undefined, undefined, '12h')).toEqual( + 'abc -12h' + ); + expect(adjustTimeScaleLabelSuffix('abc', undefined, 'h', undefined, '12h')).toEqual( + 'abc per hour -12h' + ); + }); + + it('should add and remove at the same time', () => { + expect(adjustTimeScaleLabelSuffix('abc per hour', 'h', undefined, undefined, '1d')).toEqual( + 'abc -1d' + ); + expect(adjustTimeScaleLabelSuffix('abc -1d', undefined, 'h', '1d', undefined)).toEqual( + 'abc per hour' + ); }); it('should change suffix', () => { - expect(adjustTimeScaleLabelSuffix('abc per second', 's', 'd')).toEqual('abc per day'); - expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 's')).toEqual('abc per second'); + expect(adjustTimeScaleLabelSuffix('abc per second', 's', 'd', undefined, undefined)).toEqual( + 'abc per day' + ); + expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 's', undefined, undefined)).toEqual( + 'abc per second' + ); + expect(adjustTimeScaleLabelSuffix('abc per day -3h', 'd', 's', '3h', '3h')).toEqual( + 'abc per second -3h' + ); + expect(adjustTimeScaleLabelSuffix('abc per day -3h', 'd', 'd', '3h', '4h')).toEqual( + 'abc per day -4h' + ); }); it('should keep current state', () => { - expect(adjustTimeScaleLabelSuffix('abc', undefined, undefined)).toEqual('abc'); - expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 'd')).toEqual('abc per day'); + expect(adjustTimeScaleLabelSuffix('abc', undefined, undefined, undefined, undefined)).toEqual( + 'abc' + ); + expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 'd', undefined, undefined)).toEqual( + 'abc per day' + ); + expect(adjustTimeScaleLabelSuffix('abc -1h', undefined, undefined, '1h', '1h')).toEqual( + 'abc -1h' + ); + expect(adjustTimeScaleLabelSuffix('abc per day -1h', 'd', 'd', '1h', '1h')).toEqual( + 'abc per day -1h' + ); }); it('should not fail on inconsistent input', () => { - expect(adjustTimeScaleLabelSuffix('abc', 's', undefined)).toEqual('abc'); - expect(adjustTimeScaleLabelSuffix('abc', 's', 'd')).toEqual('abc per day'); - expect(adjustTimeScaleLabelSuffix('abc per day', 's', undefined)).toEqual('abc per day'); + expect(adjustTimeScaleLabelSuffix('abc', 's', undefined, undefined, undefined)).toEqual( + 'abc' + ); + expect(adjustTimeScaleLabelSuffix('abc', 's', 'd', undefined, undefined)).toEqual( + 'abc per day' + ); + expect( + adjustTimeScaleLabelSuffix('abc per day', 's', undefined, undefined, undefined) + ).toEqual('abc per day'); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts index 07806a32665dda..a0b61060b9f3ad 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts @@ -12,24 +12,36 @@ import type { IndexPatternColumn } from './definitions'; export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit; +function getSuffix(scale: TimeScaleUnit | undefined, shift: string | undefined) { + return ( + (shift || scale ? ' ' : '') + + (scale ? unitSuffixesLong[scale] : '') + + (shift && scale ? ' ' : '') + + (shift ? `-${shift}` : '') + ); +} + export function adjustTimeScaleLabelSuffix( oldLabel: string, previousTimeScale: TimeScaleUnit | undefined, - newTimeScale: TimeScaleUnit | undefined + newTimeScale: TimeScaleUnit | undefined, + previousShift: string | undefined, + newShift: string | undefined ) { let cleanedLabel = oldLabel; // remove added suffix if column had a time scale previously - if (previousTimeScale) { - const suffixPosition = oldLabel.lastIndexOf(` ${unitSuffixesLong[previousTimeScale]}`); + if (previousTimeScale || previousShift) { + const suffix = getSuffix(previousTimeScale, previousShift); + const suffixPosition = oldLabel.lastIndexOf(suffix); if (suffixPosition !== -1) { cleanedLabel = oldLabel.substring(0, suffixPosition); } } - if (!newTimeScale) { + if (!newTimeScale && !newShift) { return cleanedLabel; } // add new suffix if column has a time scale now - return `${cleanedLabel} ${unitSuffixesLong[newTimeScale]}`; + return `${cleanedLabel}${getSuffix(newTimeScale, newShift)}`; } export function adjustTimeScaleOnOtherColumnChange( @@ -54,6 +66,12 @@ export function adjustTimeScaleOnOtherColumnChange return { ...column, timeScale: undefined, - label: adjustTimeScaleLabelSuffix(column.label, column.timeScale, undefined), + label: adjustTimeScaleLabelSuffix( + column.label, + column.timeScale, + undefined, + column.timeShift, + column.timeShift + ), }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 430e139a85ccae..49bec5f58c29cc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -54,6 +54,22 @@ function getExpressionForLayer( } }); } + + if ( + 'references' in column && + rootDef.shiftable && + rootDef.input === 'fullReference' && + column.timeShift + ) { + // inherit time shift to all referenced operations + column.references.forEach((referenceColumnId) => { + const referencedColumn = columns[referenceColumnId]; + const referenceDef = operationDefinitionMap[column.operationType]; + if (referenceDef.shiftable) { + columns[referenceColumnId] = { ...referencedColumn, timeShift: column.timeShift }; + } + }); + } }); const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const); @@ -106,6 +122,7 @@ function getExpressionForLayer( }), ]), customMetric: buildExpression({ type: 'expression', chain: [aggAst] }), + timeShift: col.timeShift, } ).toAst(); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts index 23c7adb86d34fa..a9e24c70ab8acd 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts @@ -61,14 +61,14 @@ export function isColumnInvalid( 'references' in column && Boolean(getReferencesErrors(layer, column, indexPattern).filter(Boolean).length); - return ( - !!operationDefinition.getErrorMessage?.( - layer, - columnId, - indexPattern, - operationDefinitionMap - ) || referencesHaveErrors + const operationErrorMessages = operationDefinition.getErrorMessage?.( + layer, + columnId, + indexPattern, + operationDefinitionMap ); + + return (operationErrorMessages && operationErrorMessages.length > 0) || referencesHaveErrors; } function getReferencesErrors( diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 984fbf5555949b..854466956ceed7 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -224,8 +224,18 @@ export interface Datasource { getPublicAPI: (props: PublicAPIProps) => DatasourcePublicAPI; getErrorMessages: ( state: T, - layersGroups?: Record - ) => Array<{ shortMessage: string; longMessage: string }> | undefined; + layersGroups?: Record, + dateRange?: { + fromDate: string; + toDate: string; + } + ) => + | Array<{ + shortMessage: string; + longMessage: string; + fixAction?: { label: string; newState: () => Promise }; + }> + | undefined; /** * uniqueLabels of dimensions exposed for aria-labels of dragged dimensions */ @@ -234,6 +244,10 @@ export interface Datasource { * Check the internal state integrity and returns a list of missing references */ checkIntegrity: (state: T) => string[]; + /** + * The frame calls this function to display warnings about visualization + */ + getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined; } /** @@ -673,7 +687,12 @@ export interface Visualization { getErrorMessages: ( state: T, datasourceLayers?: Record - ) => Array<{ shortMessage: string; longMessage: string }> | undefined; + ) => + | Array<{ + shortMessage: string; + longMessage: string; + }> + | undefined; /** * The frame calls this function to display warnings about visualization diff --git a/x-pack/plugins/lens/server/routes/field_stats.ts b/x-pack/plugins/lens/server/routes/field_stats.ts index 6cddd2c60f4165..6b7e197a4d5617 100644 --- a/x-pack/plugins/lens/server/routes/field_stats.ts +++ b/x-pack/plugins/lens/server/routes/field_stats.ts @@ -31,6 +31,7 @@ export async function initFieldsRoute(setup: CoreSetup) { fromDate: schema.string(), toDate: schema.string(), fieldName: schema.string(), + size: schema.maybe(schema.number()), }, { unknowns: 'allow' } ), @@ -38,7 +39,7 @@ export async function initFieldsRoute(setup: CoreSetup) { }, async (context, req, res) => { const requestClient = context.core.elasticsearch.client.asCurrentUser; - const { fromDate, toDate, fieldName, dslQuery } = req.body; + const { fromDate, toDate, fieldName, dslQuery, size } = req.body; const [{ savedObjects, elasticsearch }, { data }] = await setup.getStartServices(); const savedObjectsClient = savedObjects.getScopedClient(req); @@ -112,7 +113,7 @@ export async function initFieldsRoute(setup: CoreSetup) { } return res.ok({ - body: await getStringSamples(search, field), + body: await getStringSamples(search, field, size), }); } catch (e) { if (e instanceof SavedObjectNotFound) { @@ -245,7 +246,8 @@ export async function getNumberHistogram( export async function getStringSamples( aggSearchWithBody: (aggs: Record) => unknown, - field: IFieldType + field: IFieldType, + size = 10 ): Promise { const fieldRef = getFieldRef(field); @@ -257,7 +259,7 @@ export async function getStringSamples( top_values: { terms: { ...fieldRef, - size: 10, + size, }, }, }, diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index d0466b8814fec1..bff0b590a8e68b 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -36,6 +36,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./persistent_context')); loadTestFile(require.resolve('./colors')); loadTestFile(require.resolve('./chart_data')); + loadTestFile(require.resolve('./time_shift')); loadTestFile(require.resolve('./drag_and_drop')); loadTestFile(require.resolve('./geo_field')); loadTestFile(require.resolve('./lens_reporting')); diff --git a/x-pack/test/functional/apps/lens/time_shift.ts b/x-pack/test/functional/apps/lens/time_shift.ts new file mode 100644 index 00000000000000..57c2fc194d0c05 --- /dev/null +++ b/x-pack/test/functional/apps/lens/time_shift.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + + describe('time shift', () => { + it('should able to configure a shifted metric', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_rows > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_metrics > lns-empty-dimension', + operation: 'median', + field: 'bytes', + }); + await PageObjects.lens.openDimensionEditor('lnsDatatable_metrics > lns-dimensionTrigger'); + await PageObjects.lens.enableTimeShift(); + await PageObjects.lens.setTimeShift('6h'); + + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableCellText(0, 1)).to.eql('5,994'); + }); + + it('should able to configure a regular metric next to a shifted metric', async () => { + await PageObjects.lens.closeDimensionEditor(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_metrics > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.waitForVisualization(); + + expect(await PageObjects.lens.getDatatableCellText(2, 1)).to.eql('5,994'); + expect(await PageObjects.lens.getDatatableCellText(2, 2)).to.eql('5,722.622'); + }); + + it('should show an error if terms is used and provide a fix action', async () => { + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_rows > lns-empty-dimension', + operation: 'terms', + field: 'ip', + }); + + expect(await PageObjects.lens.hasFixAction()).to.be(true); + await PageObjects.lens.useFixAction(); + + expect(await PageObjects.lens.getDatatableCellText(2, 2)).to.eql('5,541.5'); + expect(await PageObjects.lens.getDatatableCellText(2, 3)).to.eql('3,628'); + + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.eql('Filters of ip'); + }); + }); +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index b16944cd730606..0b88ecca247c5f 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -362,6 +362,25 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }); }, + async enableTimeShift() { + await testSubjects.click('indexPattern-advanced-popover'); + await retry.try(async () => { + await testSubjects.click('indexPattern-time-shift-enable'); + }); + }, + + async setTimeShift(shift: string) { + await comboBox.setCustom('indexPattern-dimension-time-shift', shift); + }, + + async hasFixAction() { + return await testSubjects.exists('errorFixAction'); + }, + + async useFixAction() { + await testSubjects.click('errorFixAction'); + }, + // closes the dimension editor flyout async closeDimensionEditor() { await retry.try(async () => {