diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2c2f6fa11841a..b071e06f1bc54 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -340,7 +340,7 @@ #CC# /x-pack/plugins/security_solution/ @elastic/security-solution # Security Solution sub teams -/x-pack/plugins/case @elastic/security-threat-hunting +/x-pack/plugins/cases @elastic/security-threat-hunting /x-pack/plugins/timelines @elastic/security-threat-hunting /x-pack/test/case_api_integration @elastic/security-threat-hunting /x-pack/plugins/lists @elastic/security-detections-response diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 336f7e5165d07..726e4257a5aac 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,18 +21,16 @@ Delete any items that are not applicable to this PR. Delete this section if it is not applicable to this PR. -Before closing this PR, invite QA, stakeholders, and other developers to -identify risks that should be tested prior to the change/feature release. +Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. -When forming the risk matrix, consider some of the following examples and how -they may potentially impact the change: +When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | -| [See more potential risk examples](../RISK_MATRIX.mdx) | +| [See more potential risk examples](https://github.com/elastic/kibana/blob/master/RISK_MATRIX.mdx) | ### For maintainers diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index 5ab0581201959..a28a95605bc6a 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -14,9 +14,14 @@ In order to support Windows development we currently require you to use one of t As well as installing https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015]. +In addition we also require you to do the following: + +- Install https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015] +- Enable the https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development[Windows Developer Mode] +- Enable https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/fsutil-8dot3name[8.3 filename support] by running the following command in a windows command prompt with admin rights `fsutil 8dot3name set 0` + Before running the steps listed below, please make sure you have installed everything -that we require and listed above and that you are running the mentioned commands -through Git bash or WSL. +that we require and listed above and that you are running all the commands from now on through Git bash or WSL. [discrete] [[get-kibana-code]] @@ -92,6 +97,10 @@ may need to run: yarn kbn clean ---- +NOTE: Running this command is only necessary in rare circumstance where you need to recover +a consistent state when problems arise. If you need to run this command often, complete +this form to provide feedback: https://ela.st/yarn-kbn-clean + If you have failures during `yarn kbn bootstrap` you may have some corrupted packages in your yarn cache which you can clean with: 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 0000000000000..de0d41286c0bb --- /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 0000000000000..024b0766ffd7b --- /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 d4a8eddf51cfc..a96626d1a485d 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 0000000000000..8040c2939e2e4 --- /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 0000000000000..1f8bc1300a0a8 --- /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 0000000000000..d15ccbc5dc0a1 --- /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 0000000000000..44ab25cf30eb2 --- /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 0000000000000..db31e549666b4 --- /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 02e9a63d95ba3..45333b6767cac 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 0000000000000..b34fda40a3089 --- /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 0000000000000..60a1bfe0872fa --- /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/docs/setup/install/brew.asciidoc b/docs/setup/install/brew.asciidoc index e3085f6e225aa..eeba869a259d4 100644 --- a/docs/setup/install/brew.asciidoc +++ b/docs/setup/install/brew.asciidoc @@ -14,15 +14,13 @@ brew tap elastic/tap ------------------------- Once you've tapped the Elastic Homebrew repo, you can use `brew install` to -install the default distribution of {kib}: +install the **latest version** of {kib}: [source,sh] ------------------------- brew install elastic/tap/kibana-full ------------------------- -This installs the most recently released distribution of {kib}. - [[brew-layout]] ==== Directory layout for Homebrew installs diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 6e8026c4a747d..ddb906f390a2d 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -596,6 +596,10 @@ inactive socket. *Default: `"120000"`* | Paths to a PEM-encoded X.509 server certificate and its corresponding private key. These are used by {kib} to establish trust when receiving inbound SSL/TLS connections from users. +|[[server-uuid]] `server.uuid:` + | The unique identifier for this {kib} instance. + + |=== [NOTE] diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js index d3755ed7c5f29..b8d21a473c65f 100644 --- a/packages/kbn-ui-shared-deps/entry.js +++ b/packages/kbn-ui-shared-deps/entry.js @@ -44,7 +44,8 @@ export const Theme = require('./theme.ts'); export const Lodash = require('lodash'); export const LodashFp = require('lodash/fp'); -export const Fflate = require('fflate/esm/browser'); +import { unzlibSync, strFromU8 } from 'fflate'; +export const Fflate = { unzlibSync, strFromU8 }; // runtime deps which don't need to be copied across all bundles export const TsLib = require('tslib'); diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index 01cebcb15963b..458b691573e56 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -48,7 +48,8 @@ const setup = () => { }; }; -describe('createStreamingBatchedFunction()', () => { +// FLAKY: https://github.com/elastic/kibana/issues/101126 +describe.skip('createStreamingBatchedFunction()', () => { test('returns a function', () => { const { fetchStreaming } = setup(); const fn = createStreamingBatchedFunction({ diff --git a/src/plugins/data/common/search/aggs/agg_config.ts b/src/plugins/data/common/search/aggs/agg_config.ts index 283d276a22904..3c83b5bdf6084 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 28102544ae055..72ea64791fa5b 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 2932ef7325aed..6f8a8d38a4a28 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 f0f3912bf64fe..48ce54bbd61bd 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 6230ae897b170..372d487bcf7a3 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 e9ed3799b90cf..d44e634a00fe6 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 4a83ae38d34db..4cbf6562487b2 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 1b876051d009b..b9329bcb25af3 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 05a6e9eeff7d7..0b794617fb96e 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 253013238d10e..e32de6cd0a83f 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 33f20b9a40dc2..ac214c1a1591c 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 b79e57207ebd8..a980f6ac555a2 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 35b765ec0e075..e6db7665a68dd 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 e12a592448334..0d3e8a5e7f878 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 49346036ce649..22ec55506fe90 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 ece5c07c6e5f8..3b6c32595909a 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 0f5c84a477b06..0e3370cec14e5 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 5fe0ee75bfe38..ae3502bbc2588 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 8b235edacb59a..08d64e599d8a9 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 ee0f72e01e1de..89006761407f7 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 8a10d7edb3f83..fac1751290f70 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 047b9bbd8517f..c6736c5b69f7d 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 40c87db57eedc..a3a4bcc16a391 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 5eb6d2b780442..f311ab35a8d0d 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 cba4de1ad11ae..5cdbcfe857585 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 1eaca811a2481..3e4fc838dd398 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 e27179c7209ad..8bfe808aede8e 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 aa2417bbf8415..00f47d31b0398 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 22e97fe18b604..d1ce6ff463903 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 6a7ff5fa5fd40..0b3d3acd3a603 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 c48233e84404c..50b5f5b60376b 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 19d2dabc843dd..b2cfad1805b9f 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 e984df13527ca..889ed29c63ee1 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 1cc11c345e9ba..9215f7afb4c6d 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 d94e01927c851..021c5aac69e10 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 7eac992680737..7a1d8ad22fb7e 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 bad4c7baf173f..4fdb1ce6b7d81 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 e70520b743e17..7ff7f18cdbc02 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 1c0afd81a63c4..a9537e1f99ca4 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 3ebb771413665..6ddb0fdd9410d 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 ea2d2cd23edae..fee4b28882408 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 6dfbac1ecb8b4..a97834f310a49 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 bde90c563afc1..645519a668376 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 667c585226a52..1637dad561c37 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 9328597b24cfa..873765374c80a 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 7929a01c0b589..60a2882fcec58 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 0d71df240d122..468da036cea88 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 fa5120dfc3b97..1a746a86cbcd5 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 065ef8021cbda..aa73d5c44dd7f 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 925d85774c7ad..8460cb891f1e4 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 e7ef22c6faeee..edf69031c31ac 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 9aaf82e65812b..849987695dc7c 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 80787a3383c6b..c181065d2416e 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 e19fc072e1cd9..f4d4fb5451dcd 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 d0175e0c8fafe..d8e03d28bb12a 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 e9d6a619a9cd6..2f8ef74b5c2f0 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 85b54f1695493..bc20f19253eec 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 675be2323b93e..c0eb0c6c241a9 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 0000000000000..4ac47efaea347 --- /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 32775464d055f..4f255cf4c244c 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 d152ebf159a8e..61193c52a5e74 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 f35d2d47f1bf4..7633382bb8763 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 4a8972d4384c2..a4d9551da75d5 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 be6e489b17290..9f5c2ef5fad3d 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 8ec412e69d4a1..f57ba27488103 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/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index 094ac302fcebe..1fda865ebd847 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -42,6 +42,8 @@ class TutorialDirectoryUi extends React.Component { constructor(props) { super(props); + const extraTabs = getServices().addDataService.getAddDataTabs(); + this.tabs = [ { id: ALL_TAB_ID, @@ -77,7 +79,13 @@ class TutorialDirectoryUi extends React.Component { id: 'home.tutorial.tabs.sampleDataTitle', defaultMessage: 'Sample data', }), + content: , }, + ...extraTabs.map(({ id, name, component: Component }) => ({ + id, + name, + content: , + })), ]; let openTab = ALL_TAB_ID; @@ -190,8 +198,9 @@ class TutorialDirectoryUi extends React.Component { }; renderTabContent = () => { - if (this.state.selectedTabId === SAMPLE_DATA_TAB_ID) { - return ; + const tab = this.tabs.find(({ id }) => id === this.state.selectedTabId); + if (tab?.content) { + return tab.content; } return ( diff --git a/src/plugins/home/public/application/kibana_services.ts b/src/plugins/home/public/application/kibana_services.ts index 73a8ab41bcfd2..af9f956889547 100644 --- a/src/plugins/home/public/application/kibana_services.ts +++ b/src/plugins/home/public/application/kibana_services.ts @@ -20,6 +20,7 @@ import { UiCounterMetricType } from '@kbn/analytics'; import { TelemetryPluginStart } from '../../../telemetry/public'; import { UrlForwardingStart } from '../../../url_forwarding/public'; import { TutorialService } from '../services/tutorials'; +import { AddDataService } from '../services/add_data'; import { FeatureCatalogueRegistry } from '../services/feature_catalogue'; import { EnvironmentService } from '../services/environment'; import { ConfigSchema } from '../../config'; @@ -44,6 +45,7 @@ export interface HomeKibanaServices { environmentService: EnvironmentService; telemetry?: TelemetryPluginStart; tutorialService: TutorialService; + addDataService: AddDataService; } let services: HomeKibanaServices | null = null; diff --git a/src/plugins/home/public/mocks.ts b/src/plugins/home/public/mocks.ts index 32bec31153ba0..10c186ee3f4e3 100644 --- a/src/plugins/home/public/mocks.ts +++ b/src/plugins/home/public/mocks.ts @@ -10,11 +10,13 @@ import { featureCatalogueRegistryMock } from './services/feature_catalogue/featu import { environmentServiceMock } from './services/environment/environment.mock'; import { configSchema } from '../config'; import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock'; +import { addDataServiceMock } from './services/add_data/add_data_service.mock'; const createSetupContract = () => ({ featureCatalogue: featureCatalogueRegistryMock.createSetup(), environment: environmentServiceMock.createSetup(), tutorials: tutorialServiceMock.createSetup(), + addData: addDataServiceMock.createSetup(), config: configSchema.validate({}), }); diff --git a/src/plugins/home/public/plugin.test.mocks.ts b/src/plugins/home/public/plugin.test.mocks.ts index 779ab2e700352..c3e3c50a2fe0f 100644 --- a/src/plugins/home/public/plugin.test.mocks.ts +++ b/src/plugins/home/public/plugin.test.mocks.ts @@ -9,12 +9,15 @@ import { featureCatalogueRegistryMock } from './services/feature_catalogue/feature_catalogue_registry.mock'; import { environmentServiceMock } from './services/environment/environment.mock'; import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock'; +import { addDataServiceMock } from './services/add_data/add_data_service.mock'; export const registryMock = featureCatalogueRegistryMock.create(); export const environmentMock = environmentServiceMock.create(); export const tutorialMock = tutorialServiceMock.create(); +export const addDataMock = addDataServiceMock.create(); jest.doMock('./services', () => ({ FeatureCatalogueRegistry: jest.fn(() => registryMock), EnvironmentService: jest.fn(() => environmentMock), TutorialService: jest.fn(() => tutorialMock), + AddDataService: jest.fn(() => addDataMock), })); diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index 89c7600a1d85d..b3b5ce487b747 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -24,6 +24,8 @@ import { FeatureCatalogueRegistrySetup, TutorialService, TutorialServiceSetup, + AddDataService, + AddDataServiceSetup, } from './services'; import { ConfigSchema } from '../config'; import { setServices } from './application/kibana_services'; @@ -56,6 +58,7 @@ export class HomePublicPlugin private readonly featuresCatalogueRegistry = new FeatureCatalogueRegistry(); private readonly environmentService = new EnvironmentService(); private readonly tutorialService = new TutorialService(); + private readonly addDataService = new AddDataService(); constructor(private readonly initializerContext: PluginInitializerContext) {} @@ -94,6 +97,7 @@ export class HomePublicPlugin urlForwarding: urlForwardingStart, homeConfig: this.initializerContext.config.get(), tutorialService: this.tutorialService, + addDataService: this.addDataService, featureCatalogue: this.featuresCatalogueRegistry, }); coreStart.chrome.docTitle.change( @@ -126,6 +130,7 @@ export class HomePublicPlugin featureCatalogue, environment: { ...this.environmentService.setup() }, tutorials: { ...this.tutorialService.setup() }, + addData: { ...this.addDataService.setup() }, }; } @@ -163,9 +168,13 @@ export type EnvironmentSetup = EnvironmentServiceSetup; /** @public */ export type TutorialSetup = TutorialServiceSetup; +/** @public */ +export type AddDataSetup = AddDataServiceSetup; + /** @public */ export interface HomePublicPluginSetup { tutorials: TutorialServiceSetup; + addData: AddDataServiceSetup; featureCatalogue: FeatureCatalogueSetup; /** * The environment service is only available for a transition period and will diff --git a/src/plugins/home/public/services/add_data/add_data_service.mock.ts b/src/plugins/home/public/services/add_data/add_data_service.mock.ts new file mode 100644 index 0000000000000..e0b4d12909791 --- /dev/null +++ b/src/plugins/home/public/services/add_data/add_data_service.mock.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { PublicMethodsOf } from '@kbn/utility-types'; +import { AddDataService, AddDataServiceSetup } from './add_data_service'; + +const createSetupMock = (): jest.Mocked => { + const setup = { + registerAddDataTab: jest.fn(), + }; + return setup; +}; + +const createMock = (): jest.Mocked> => { + const service = { + setup: jest.fn(), + getAddDataTabs: jest.fn(() => []), + }; + service.setup.mockImplementation(createSetupMock); + return service; +}; + +export const addDataServiceMock = { + createSetup: createSetupMock, + create: createMock, +}; diff --git a/src/plugins/home/public/services/add_data/add_data_service.test.tsx b/src/plugins/home/public/services/add_data/add_data_service.test.tsx new file mode 100644 index 0000000000000..b04b80ac19eec --- /dev/null +++ b/src/plugins/home/public/services/add_data/add_data_service.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 React from 'react'; +import { AddDataService } from './add_data_service'; + +describe('AddDataService', () => { + describe('setup', () => { + test('allows multiple register directory header link calls', () => { + const setup = new AddDataService().setup(); + expect(() => { + setup.registerAddDataTab({ id: 'abc', name: 'a b c', component: () => 123 }); + setup.registerAddDataTab({ id: 'def', name: 'a b c', component: () => 456 }); + }).not.toThrow(); + }); + + test('throws when same directory header link is registered twice', () => { + const setup = new AddDataService().setup(); + expect(() => { + setup.registerAddDataTab({ id: 'abc', name: 'a b c', component: () => 123 }); + setup.registerAddDataTab({ id: 'abc', name: 'a b c', component: () => 456 }); + }).toThrow(); + }); + }); + + describe('getDirectoryHeaderLinks', () => { + test('returns empty array', () => { + const service = new AddDataService(); + expect(service.getAddDataTabs()).toEqual([]); + }); + + test('returns last state of register calls', () => { + const service = new AddDataService(); + const setup = service.setup(); + const links = [ + { id: 'abc', name: 'a b c', component: () => 123 }, + { id: 'def', name: 'a b c', component: () => 456 }, + ]; + setup.registerAddDataTab(links[0]); + setup.registerAddDataTab(links[1]); + expect(service.getAddDataTabs()).toEqual(links); + }); + }); +}); diff --git a/src/plugins/home/public/services/add_data/add_data_service.ts b/src/plugins/home/public/services/add_data/add_data_service.ts new file mode 100644 index 0000000000000..668c373f8314d --- /dev/null +++ b/src/plugins/home/public/services/add_data/add_data_service.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; + +/** @public */ +export interface AddDataTab { + id: string; + name: string; + component: React.FC; +} + +export class AddDataService { + private addDataTabs: Record = {}; + + public setup() { + return { + /** + * Registers a component that will be rendered as a new tab in the Add data page + */ + registerAddDataTab: (tab: AddDataTab) => { + if (this.addDataTabs[tab.id]) { + throw new Error(`Tab ${tab.id} already exists`); + } + this.addDataTabs[tab.id] = tab; + }, + }; + } + + public getAddDataTabs() { + return Object.values(this.addDataTabs); + } +} + +export type AddDataServiceSetup = ReturnType; diff --git a/src/plugins/home/public/services/add_data/index.ts b/src/plugins/home/public/services/add_data/index.ts new file mode 100644 index 0000000000000..f2367ca320e9f --- /dev/null +++ b/src/plugins/home/public/services/add_data/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export { AddDataService } from './add_data_service'; + +export type { AddDataServiceSetup, AddDataTab } from './add_data_service'; diff --git a/src/plugins/home/public/services/index.ts b/src/plugins/home/public/services/index.ts index 8cd4c8d84e0f7..65913df6310b1 100644 --- a/src/plugins/home/public/services/index.ts +++ b/src/plugins/home/public/services/index.ts @@ -26,3 +26,6 @@ export type { TutorialDirectoryHeaderLinkComponent, TutorialModuleNoticeComponent, } from './tutorials'; + +export { AddDataService } from './add_data'; +export type { AddDataServiceSetup, AddDataTab } from './add_data'; diff --git a/src/plugins/newsfeed/common/constants.ts b/src/plugins/newsfeed/common/constants.ts index 7ff078e82810b..6ba5e07ea873e 100644 --- a/src/plugins/newsfeed/common/constants.ts +++ b/src/plugins/newsfeed/common/constants.ts @@ -7,11 +7,6 @@ */ export const NEWSFEED_FALLBACK_LANGUAGE = 'en'; -export const NEWSFEED_FALLBACK_FETCH_INTERVAL = 86400000; // 1 day -export const NEWSFEED_FALLBACK_MAIN_INTERVAL = 120000; // 2 minutes -export const NEWSFEED_LAST_FETCH_STORAGE_KEY = 'newsfeed.lastfetchtime'; -export const NEWSFEED_HASH_SET_STORAGE_KEY = 'newsfeed.hashes'; - export const NEWSFEED_DEFAULT_SERVICE_BASE_URL = 'https://feeds.elastic.co'; export const NEWSFEED_DEV_SERVICE_BASE_URL = 'https://feeds-staging.elastic.co'; export const NEWSFEED_DEFAULT_SERVICE_PATH = '/kibana/v{VERSION}.json'; diff --git a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx index 142d4286b363b..7060adcc2a4ec 100644 --- a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx +++ b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import React, { useState, Fragment, useEffect } from 'react'; -import * as Rx from 'rxjs'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { EuiHeaderSectionItemButton, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { NewsfeedApi } from '../lib/api'; import { NewsfeedFlyout } from './flyout_list'; import { FetchResult } from '../types'; @@ -17,46 +17,44 @@ export interface INewsfeedContext { setFlyoutVisible: React.Dispatch>; newsFetchResult: FetchResult | void | null; } -export const NewsfeedContext = React.createContext({} as INewsfeedContext); -export type NewsfeedApiFetchResult = Rx.Observable; +export const NewsfeedContext = React.createContext({} as INewsfeedContext); export interface Props { - apiFetchResult: NewsfeedApiFetchResult; + newsfeedApi: NewsfeedApi; } -export const NewsfeedNavButton = ({ apiFetchResult }: Props) => { - const [showBadge, setShowBadge] = useState(false); +export const NewsfeedNavButton = ({ newsfeedApi }: Props) => { const [flyoutVisible, setFlyoutVisible] = useState(false); const [newsFetchResult, setNewsFetchResult] = useState(null); + const hasNew = useMemo(() => { + return newsFetchResult ? newsFetchResult.hasNew : false; + }, [newsFetchResult]); useEffect(() => { - function handleStatusChange(fetchResult: FetchResult | void | null) { - if (fetchResult) { - setShowBadge(fetchResult.hasNew); - } - setNewsFetchResult(fetchResult); - } - - const subscription = apiFetchResult.subscribe((res) => handleStatusChange(res)); + const subscription = newsfeedApi.fetchResults$.subscribe((results) => { + setNewsFetchResult(results); + }); return () => subscription.unsubscribe(); - }, [apiFetchResult]); + }, [newsfeedApi]); - function showFlyout() { - setShowBadge(false); + const showFlyout = useCallback(() => { + if (newsFetchResult) { + newsfeedApi.markAsRead(newsFetchResult.feedItems.map((item) => item.hash)); + } setFlyoutVisible(!flyoutVisible); - } + }, [newsfeedApi, newsFetchResult, flyoutVisible]); return ( - + <> { defaultMessage: 'Newsfeed menu - all items read', }) } - notification={showBadge ? true : null} + notification={hasNew ? true : null} onClick={showFlyout} > {flyoutVisible ? : null} - + ); }; diff --git a/src/plugins/newsfeed/public/lib/api.test.mocks.ts b/src/plugins/newsfeed/public/lib/api.test.mocks.ts new file mode 100644 index 0000000000000..677bc203cbef3 --- /dev/null +++ b/src/plugins/newsfeed/public/lib/api.test.mocks.ts @@ -0,0 +1,20 @@ +/* + * 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 { storageMock } from './storage.mock'; +import { driverMock } from './driver.mock'; + +export const storageInstanceMock = storageMock.create(); +jest.doMock('./storage', () => ({ + NewsfeedStorage: jest.fn().mockImplementation(() => storageInstanceMock), +})); + +export const driverInstanceMock = driverMock.create(); +jest.doMock('./driver', () => ({ + NewsfeedApiDriver: jest.fn().mockImplementation(() => driverInstanceMock), +})); diff --git a/src/plugins/newsfeed/public/lib/api.test.ts b/src/plugins/newsfeed/public/lib/api.test.ts index e142ffb4f6989..a4894573932e6 100644 --- a/src/plugins/newsfeed/public/lib/api.test.ts +++ b/src/plugins/newsfeed/public/lib/api.test.ts @@ -6,689 +6,120 @@ * Side Public License, v 1. */ -import { take, tap, toArray } from 'rxjs/operators'; -import { interval, race } from 'rxjs'; -import sinon, { stub } from 'sinon'; +import { driverInstanceMock, storageInstanceMock } from './api.test.mocks'; import moment from 'moment'; -import { HttpSetup } from 'src/core/public'; -import { - NEWSFEED_HASH_SET_STORAGE_KEY, - NEWSFEED_LAST_FETCH_STORAGE_KEY, -} from '../../common/constants'; -import { ApiItem, NewsfeedItem, NewsfeedPluginBrowserConfig } from '../types'; -import { NewsfeedApiDriver, getApi } from './api'; +import { getApi } from './api'; +import { TestScheduler } from 'rxjs/testing'; +import { FetchResult, NewsfeedPluginBrowserConfig } from '../types'; +import { take } from 'rxjs/operators'; -const localStorageGet = sinon.stub(); -const sessionStoragetGet = sinon.stub(); +const kibanaVersion = '8.0.0'; +const newsfeedId = 'test'; -Object.defineProperty(window, 'localStorage', { - value: { - getItem: localStorageGet, - setItem: stub(), - }, - writable: true, -}); -Object.defineProperty(window, 'sessionStorage', { - value: { - getItem: sessionStoragetGet, - setItem: stub(), - }, - writable: true, -}); - -jest.mock('uuid', () => ({ - v4: () => 'NEW_UUID', -})); - -describe('NewsfeedApiDriver', () => { - const kibanaVersion = '99.999.9-test_version'; // It'll remove the `-test_version` bit - const userLanguage = 'en'; - const fetchInterval = 2000; - const getDriver = () => new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval); - - afterEach(() => { - sinon.reset(); - }); - - describe('shouldFetch', () => { - it('defaults to true', () => { - const driver = getDriver(); - expect(driver.shouldFetch()).toBe(true); - }); - - it('returns true if last fetch time precedes page load time', () => { - sessionStoragetGet.throws('Wrong key passed!'); - sessionStoragetGet - .withArgs(`${NEWSFEED_LAST_FETCH_STORAGE_KEY}.NEW_UUID`) - .returns(322642800000); // 1980-03-23 - const driver = getDriver(); - expect(driver.shouldFetch()).toBe(true); - }); - - it('returns false if last fetch time is recent enough', () => { - sessionStoragetGet.throws('Wrong key passed!'); - sessionStoragetGet - .withArgs(`${NEWSFEED_LAST_FETCH_STORAGE_KEY}.NEW_UUID`) - .returns(3005017200000); // 2065-03-23 - const driver = getDriver(); - expect(driver.shouldFetch()).toBe(false); - }); - }); - - describe('updateHashes', () => { - it('returns previous and current storage', () => { - const driver = getDriver(); - const items: NewsfeedItem[] = [ - { - title: 'Good news, everyone!', - description: 'good item description', - linkText: 'click here', - linkUrl: 'about:blank', - badge: 'test', - publishOn: moment(1572489035150), - expireOn: moment(1572489047858), - hash: 'hash1oneoneoneone', - }, - ]; - expect(driver.updateHashes(items)).toMatchInlineSnapshot(` - Object { - "current": Array [ - "hash1oneoneoneone", - ], - "previous": Array [], - } - `); - }); - - it('concatenates the previous hashes with the current', () => { - localStorageGet.throws('Wrong key passed!'); - localStorageGet.withArgs(`${NEWSFEED_HASH_SET_STORAGE_KEY}.NEW_UUID`).returns('happyness'); - const driver = getDriver(); - const items: NewsfeedItem[] = [ - { - title: 'Better news, everyone!', - description: 'better item description', - linkText: 'click there', - linkUrl: 'about:blank', - badge: 'concatentated', - publishOn: moment(1572489035150), - expireOn: moment(1572489047858), - hash: 'three33hash', - }, - ]; - expect(driver.updateHashes(items)).toMatchInlineSnapshot(` - Object { - "current": Array [ - "happyness", - "three33hash", - ], - "previous": Array [ - "happyness", - ], - } - `); - }); - }); - - it('Validates items for required fields', () => { - const driver = getDriver(); - expect(driver.validateItem({})).toBe(false); - expect( - driver.validateItem({ - title: 'Gadzooks!', - description: 'gadzooks item description', - linkText: 'click here', - linkUrl: 'about:blank', - badge: 'test', - publishOn: moment(1572489035150), - expireOn: moment(1572489047858), - hash: 'hash2twotwotwotwotwo', - }) - ).toBe(true); - expect( - driver.validateItem({ - title: 'Gadzooks!', - description: 'gadzooks item description', - linkText: 'click here', - linkUrl: 'about:blank', - publishOn: moment(1572489035150), - hash: 'hash2twotwotwotwotwo', - }) - ).toBe(true); - expect( - driver.validateItem({ - title: 'Gadzooks!', - description: 'gadzooks item description', - linkText: 'click here', - linkUrl: 'about:blank', - publishOn: moment(1572489035150), - // hash: 'hash2twotwotwotwotwo', // should fail because this is missing - }) - ).toBe(false); +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); }); - describe('modelItems', () => { - it('Models empty set with defaults', () => { - const driver = getDriver(); - const apiItems: ApiItem[] = []; - expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(` - Object { - "error": null, - "feedItems": Array [], - "hasNew": false, - "kibanaVersion": "99.999.9", - } - `); - }); - - it('Selects default language', () => { - const driver = getDriver(); - const apiItems: ApiItem[] = [ - { - title: { - en: 'speaking English', - es: 'habla Espanol', - }, - description: { - en: 'language test', - es: 'idiomas', - }, - languages: ['en', 'es'], - link_text: { - en: 'click here', - es: 'aqui', - }, - link_url: { - en: 'xyzxyzxyz', - es: 'abcabc', - }, - badge: { - en: 'firefighter', - es: 'bombero', - }, - publish_on: new Date('2014-10-31T04:23:47Z'), - expire_on: new Date('2049-10-31T04:23:47Z'), - hash: 'abcabc1231123123hash', - }, - ]; - expect(driver.modelItems(apiItems)).toMatchObject({ - error: null, - feedItems: [ - { - badge: 'firefighter', - description: 'language test', - hash: 'abcabc1231', - linkText: 'click here', - linkUrl: 'xyzxyzxyz', - title: 'speaking English', - }, - ], - hasNew: true, - kibanaVersion: '99.999.9', - }); - }); - - it("Falls back to English when user language isn't present", () => { - // Set Language to French - const driver = new NewsfeedApiDriver(kibanaVersion, 'fr', fetchInterval); - const apiItems: ApiItem[] = [ - { - title: { - en: 'speaking English', - fr: 'Le Title', - }, - description: { - en: 'not French', - fr: 'Le Description', - }, - languages: ['en', 'fr'], - link_text: { - en: 'click here', - fr: 'Le Link Text', - }, - link_url: { - en: 'xyzxyzxyz', - fr: 'le_url', - }, - badge: { - en: 'firefighter', - fr: 'le_badge', - }, - publish_on: new Date('2014-10-31T04:23:47Z'), - expire_on: new Date('2049-10-31T04:23:47Z'), - hash: 'frfrfrfr1231123123hash', - }, // fallback: no - { - title: { - en: 'speaking English', - es: 'habla Espanol', - }, - description: { - en: 'not French', - es: 'no Espanol', - }, - languages: ['en', 'es'], - link_text: { - en: 'click here', - es: 'aqui', - }, - link_url: { - en: 'xyzxyzxyz', - es: 'abcabc', - }, - badge: { - en: 'firefighter', - es: 'bombero', - }, - publish_on: new Date('2014-10-31T04:23:47Z'), - expire_on: new Date('2049-10-31T04:23:47Z'), - hash: 'enenenen1231123123hash', - }, // fallback: yes - ]; - expect(driver.modelItems(apiItems)).toMatchObject({ - error: null, - feedItems: [ - { - badge: 'le_badge', - description: 'Le Description', - hash: 'frfrfrfr12', - linkText: 'Le Link Text', - linkUrl: 'le_url', - title: 'Le Title', - }, - { - badge: 'firefighter', - description: 'not French', - hash: 'enenenen12', - linkText: 'click here', - linkUrl: 'xyzxyzxyz', - title: 'speaking English', - }, - ], - hasNew: true, - kibanaVersion: '99.999.9', - }); - }); - - it('Models multiple items into an API FetchResult', () => { - const driver = getDriver(); - const apiItems: ApiItem[] = [ - { - title: { - en: 'guess what', - }, - description: { - en: 'this tests the modelItems function', - }, - link_text: { - en: 'click here', - }, - link_url: { - en: 'about:blank', - }, - publish_on: new Date('2014-10-31T04:23:47Z'), - expire_on: new Date('2049-10-31T04:23:47Z'), - hash: 'abcabc1231123123hash', - }, - { - title: { - en: 'guess when', - }, - description: { - en: 'this also tests the modelItems function', - }, - link_text: { - en: 'click here', - }, - link_url: { - en: 'about:blank', - }, - badge: { - en: 'hero', - }, - publish_on: new Date('2014-10-31T04:23:47Z'), - expire_on: new Date('2049-10-31T04:23:47Z'), - hash: 'defdefdef456456456', - }, - ]; - expect(driver.modelItems(apiItems)).toMatchObject({ - error: null, - feedItems: [ - { - badge: null, - description: 'this tests the modelItems function', - hash: 'abcabc1231', - linkText: 'click here', - linkUrl: 'about:blank', - title: 'guess what', - }, - { - badge: 'hero', - description: 'this also tests the modelItems function', - hash: 'defdefdef4', - linkText: 'click here', - linkUrl: 'about:blank', - title: 'guess when', - }, - ], - hasNew: true, - kibanaVersion: '99.999.9', - }); - }); - - it('Filters expired', () => { - const driver = getDriver(); - const apiItems: ApiItem[] = [ - { - title: { - en: 'guess what', - }, - description: { - en: 'this tests the modelItems function', - }, - link_text: { - en: 'click here', - }, - link_url: { - en: 'about:blank', - }, - publish_on: new Date('2013-10-31T04:23:47Z'), - expire_on: new Date('2014-10-31T04:23:47Z'), // too old - hash: 'abcabc1231123123hash', - }, - ]; - expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(` - Object { - "error": null, - "feedItems": Array [], - "hasNew": false, - "kibanaVersion": "99.999.9", - } - `); - }); +const createConfig = (mainInternal: number): NewsfeedPluginBrowserConfig => ({ + mainInterval: moment.duration(mainInternal, 'ms'), + fetchInterval: moment.duration(mainInternal, 'ms'), + service: { + urlRoot: 'urlRoot', + pathTemplate: 'pathTemplate', + }, +}); - it('Filters pre-published', () => { - const driver = getDriver(); - const apiItems: ApiItem[] = [ - { - title: { - en: 'guess what', - }, - description: { - en: 'this tests the modelItems function', - }, - link_text: { - en: 'click here', - }, - link_url: { - en: 'about:blank', - }, - publish_on: new Date('2055-10-31T04:23:47Z'), // too new - expire_on: new Date('2056-10-31T04:23:47Z'), - hash: 'abcabc1231123123hash', - }, - ]; - expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(` - Object { - "error": null, - "feedItems": Array [], - "hasNew": false, - "kibanaVersion": "99.999.9", - } - `); - }); - }); +const createFetchResult = (parts: Partial): FetchResult => ({ + feedItems: [], + hasNew: false, + error: null, + kibanaVersion, + ...parts, }); describe('getApi', () => { - const mockHttpGet = jest.fn(); - let httpMock = ({ - fetch: mockHttpGet, - } as unknown) as HttpSetup; - const getHttpMockWithItems = (mockApiItems: ApiItem[]) => ( - arg1: string, - arg2: { method: string } - ) => { - if ( - arg1 === 'http://fakenews.co/kibana-test/v6.8.2.json' && - arg2.method && - arg2.method === 'GET' - ) { - return Promise.resolve({ items: mockApiItems }); - } - return Promise.reject('wrong args!'); - }; - let configMock: NewsfeedPluginBrowserConfig; - - afterEach(() => { - jest.resetAllMocks(); - }); - beforeEach(() => { - configMock = { - service: { - urlRoot: 'http://fakenews.co', - pathTemplate: '/kibana-test/v{VERSION}.json', - }, - mainInterval: moment.duration(86400000), - fetchInterval: moment.duration(86400000), - }; - httpMock = ({ - fetch: mockHttpGet, - } as unknown) as HttpSetup; + driverInstanceMock.shouldFetch.mockReturnValue(true); }); - it('creates a result', (done) => { - mockHttpGet.mockImplementationOnce(() => Promise.resolve({ items: [] })); - getApi(httpMock, configMock, '6.8.2').subscribe((result) => { - expect(result).toMatchInlineSnapshot(` - Object { - "error": null, - "feedItems": Array [], - "hasNew": false, - "kibanaVersion": "6.8.2", - } - `); - done(); - }); + afterEach(() => { + storageInstanceMock.isAnyUnread$.mockReset(); + driverInstanceMock.fetchNewsfeedItems.mockReset(); }); - it('hasNew is true when the service returns hashes not in the cache', (done) => { - const mockApiItems: ApiItem[] = [ - { - title: { - en: 'speaking English', - es: 'habla Espanol', - }, - description: { - en: 'language test', - es: 'idiomas', - }, - languages: ['en', 'es'], - link_text: { - en: 'click here', - es: 'aqui', - }, - link_url: { - en: 'xyzxyzxyz', - es: 'abcabc', - }, - badge: { - en: 'firefighter', - es: 'bombero', - }, - publish_on: new Date('2014-10-31T04:23:47Z'), - expire_on: new Date('2049-10-31T04:23:47Z'), - hash: 'abcabc1231123123hash', - }, - ]; - - mockHttpGet.mockImplementationOnce(getHttpMockWithItems(mockApiItems)); - - getApi(httpMock, configMock, '6.8.2').subscribe((result) => { - expect(result).toMatchInlineSnapshot(` - Object { - "error": null, - "feedItems": Array [ - Object { - "badge": "firefighter", - "description": "language test", - "expireOn": "2049-10-31T04:23:47.000Z", - "hash": "abcabc1231", - "linkText": "click here", - "linkUrl": "xyzxyzxyz", - "publishOn": "2014-10-31T04:23:47.000Z", - "title": "speaking English", - }, - ], - "hasNew": true, - "kibanaVersion": "6.8.2", - } - `); - done(); - }); - }); + it('merges the newsfeed and unread observables', () => { + getTestScheduler().run(({ expectObservable, cold }) => { + storageInstanceMock.isAnyUnread$.mockImplementation(() => { + return cold('a|', { + a: true, + }); + }); + driverInstanceMock.fetchNewsfeedItems.mockReturnValue( + cold('a|', { + a: createFetchResult({ feedItems: ['item' as any] }), + }) + ); + const api = getApi(createConfig(1000), kibanaVersion, newsfeedId); - it('hasNew is false when service returns hashes that are all stored', (done) => { - localStorageGet.throws('Wrong key passed!'); - localStorageGet.withArgs(`${NEWSFEED_HASH_SET_STORAGE_KEY}.NEW_UUID`).returns('happyness'); - const mockApiItems: ApiItem[] = [ - { - title: { en: 'hasNew test' }, - description: { en: 'test' }, - link_text: { en: 'click here' }, - link_url: { en: 'xyzxyzxyz' }, - badge: { en: 'firefighter' }, - publish_on: new Date('2014-10-31T04:23:47Z'), - expire_on: new Date('2049-10-31T04:23:47Z'), - hash: 'happyness', - }, - ]; - mockHttpGet.mockImplementationOnce(getHttpMockWithItems(mockApiItems)); - getApi(httpMock, configMock, '6.8.2').subscribe((result) => { - expect(result).toMatchInlineSnapshot(` - Object { - "error": null, - "feedItems": Array [ - Object { - "badge": "firefighter", - "description": "test", - "expireOn": "2049-10-31T04:23:47.000Z", - "hash": "happyness", - "linkText": "click here", - "linkUrl": "xyzxyzxyz", - "publishOn": "2014-10-31T04:23:47.000Z", - "title": "hasNew test", - }, - ], - "hasNew": false, - "kibanaVersion": "6.8.2", - } - `); - done(); + expectObservable(api.fetchResults$.pipe(take(1))).toBe('(a|)', { + a: createFetchResult({ + feedItems: ['item' as any], + hasNew: true, + }), + }); }); }); - it('forwards an error', (done) => { - mockHttpGet.mockImplementationOnce((arg1, arg2) => Promise.reject('sorry, try again later!')); - - getApi(httpMock, configMock, '6.8.2').subscribe((result) => { - expect(result).toMatchInlineSnapshot(` - Object { - "error": "sorry, try again later!", - "feedItems": Array [], - "hasNew": false, - "kibanaVersion": "6.8.2", - } - `); - done(); + it('emits based on the predefined interval', () => { + getTestScheduler().run(({ expectObservable, cold }) => { + storageInstanceMock.isAnyUnread$.mockImplementation(() => { + return cold('a|', { + a: true, + }); + }); + driverInstanceMock.fetchNewsfeedItems.mockReturnValue( + cold('a|', { + a: createFetchResult({ feedItems: ['item' as any] }), + }) + ); + const api = getApi(createConfig(2), kibanaVersion, newsfeedId); + + expectObservable(api.fetchResults$.pipe(take(2))).toBe('a-(b|)', { + a: createFetchResult({ + feedItems: ['item' as any], + hasNew: true, + }), + b: createFetchResult({ + feedItems: ['item' as any], + hasNew: true, + }), + }); }); }); - describe('Retry fetching', () => { - const successItems: ApiItem[] = [ - { - title: { en: 'hasNew test' }, - description: { en: 'test' }, - link_text: { en: 'click here' }, - link_url: { en: 'xyzxyzxyz' }, - badge: { en: 'firefighter' }, - publish_on: new Date('2014-10-31T04:23:47Z'), - expire_on: new Date('2049-10-31T04:23:47Z'), - hash: 'happyness', - }, - ]; - - it("retries until fetch doesn't error", (done) => { - configMock.mainInterval = moment.duration(10); // fast retry for testing - mockHttpGet - .mockImplementationOnce(() => Promise.reject('Sorry, try again later!')) - .mockImplementationOnce(() => Promise.reject('Sorry, internal server error!')) - .mockImplementationOnce(() => Promise.reject("Sorry, it's too cold to go outside!")) - .mockImplementationOnce(getHttpMockWithItems(successItems)); - - getApi(httpMock, configMock, '6.8.2') - .pipe(take(4), toArray()) - .subscribe((result) => { - expect(result).toMatchInlineSnapshot(` - Array [ - Object { - "error": "Sorry, try again later!", - "feedItems": Array [], - "hasNew": false, - "kibanaVersion": "6.8.2", - }, - Object { - "error": "Sorry, internal server error!", - "feedItems": Array [], - "hasNew": false, - "kibanaVersion": "6.8.2", - }, - Object { - "error": "Sorry, it's too cold to go outside!", - "feedItems": Array [], - "hasNew": false, - "kibanaVersion": "6.8.2", - }, - Object { - "error": null, - "feedItems": Array [ - Object { - "badge": "firefighter", - "description": "test", - "expireOn": "2049-10-31T04:23:47.000Z", - "hash": "happyness", - "linkText": "click here", - "linkUrl": "xyzxyzxyz", - "publishOn": "2014-10-31T04:23:47.000Z", - "title": "hasNew test", - }, - ], - "hasNew": false, - "kibanaVersion": "6.8.2", - }, - ] - `); - done(); + it('re-emits when the unread status changes', () => { + getTestScheduler().run(({ expectObservable, cold }) => { + storageInstanceMock.isAnyUnread$.mockImplementation(() => { + return cold('a--b', { + a: true, + b: false, }); - }); - - it("doesn't retry if fetch succeeds", (done) => { - configMock.mainInterval = moment.duration(10); // fast retry for testing - mockHttpGet.mockImplementation(getHttpMockWithItems(successItems)); - - const timeout$ = interval(1000); // lets us capture some results after a short time - let timesFetched = 0; - - const get$ = getApi(httpMock, configMock, '6.8.2').pipe( - tap(() => { - timesFetched++; + }); + driverInstanceMock.fetchNewsfeedItems.mockReturnValue( + cold('(a|)', { + a: createFetchResult({}), }) ); - - race(get$, timeout$).subscribe(() => { - expect(timesFetched).toBe(1); // first fetch was successful, so there was no retry - done(); + const api = getApi(createConfig(10), kibanaVersion, newsfeedId); + + expectObservable(api.fetchResults$.pipe(take(2))).toBe('a--(b|)', { + a: createFetchResult({ + hasNew: true, + }), + b: createFetchResult({ + hasNew: false, + }), }); }); }); diff --git a/src/plugins/newsfeed/public/lib/api.ts b/src/plugins/newsfeed/public/lib/api.ts index 9b1274a25d486..4fbbd8687b73f 100644 --- a/src/plugins/newsfeed/public/lib/api.ts +++ b/src/plugins/newsfeed/public/lib/api.ts @@ -6,21 +6,12 @@ * Side Public License, v 1. */ -import * as Rx from 'rxjs'; -import moment from 'moment'; -import uuid from 'uuid'; +import { combineLatest, Observable, timer, of } from 'rxjs'; +import { map, catchError, filter, mergeMap, tap } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; -import { catchError, filter, mergeMap, tap } from 'rxjs/operators'; -import { HttpSetup } from 'src/core/public'; -import { - NEWSFEED_DEFAULT_SERVICE_BASE_URL, - NEWSFEED_FALLBACK_LANGUAGE, - NEWSFEED_LAST_FETCH_STORAGE_KEY, - NEWSFEED_HASH_SET_STORAGE_KEY, -} from '../../common/constants'; -import { ApiItem, NewsfeedItem, FetchResult, NewsfeedPluginBrowserConfig } from '../types'; - -type ApiConfig = NewsfeedPluginBrowserConfig['service']; +import { FetchResult, NewsfeedPluginBrowserConfig } from '../types'; +import { NewsfeedApiDriver } from './driver'; +import { NewsfeedStorage } from './storage'; export enum NewsfeedApiEndpoint { KIBANA = 'kibana', @@ -29,145 +20,17 @@ export enum NewsfeedApiEndpoint { OBSERVABILITY = 'observability', } -export class NewsfeedApiDriver { - private readonly id = uuid.v4(); - private readonly kibanaVersion: string; - private readonly loadedTime = moment().utc(); // the date is compared to time in UTC format coming from the service - private readonly lastFetchStorageKey: string; - private readonly hashSetStorageKey: string; - - constructor( - kibanaVersion: string, - private readonly userLanguage: string, - private readonly fetchInterval: number - ) { - // The API only accepts versions in the format `X.Y.Z`, so we need to drop the `-SNAPSHOT` or any other label after it - this.kibanaVersion = kibanaVersion.replace(/^(\d+\.\d+\.\d+).*/, '$1'); - this.lastFetchStorageKey = `${NEWSFEED_LAST_FETCH_STORAGE_KEY}.${this.id}`; - this.hashSetStorageKey = `${NEWSFEED_HASH_SET_STORAGE_KEY}.${this.id}`; - } - - shouldFetch(): boolean { - const lastFetchUtc: string | null = sessionStorage.getItem(this.lastFetchStorageKey); - if (lastFetchUtc == null) { - return true; - } - const last = moment(lastFetchUtc, 'x'); // parse as unix ms timestamp (already is UTC) - - // does the last fetch time precede the time that the page was loaded? - if (this.loadedTime.diff(last) > 0) { - return true; - } - - const now = moment.utc(); // always use UTC to compare timestamps that came from the service - const duration = moment.duration(now.diff(last)); - - return duration.asMilliseconds() > this.fetchInterval; - } - - updateLastFetch() { - sessionStorage.setItem(this.lastFetchStorageKey, Date.now().toString()); - } - - updateHashes(items: NewsfeedItem[]): { previous: string[]; current: string[] } { - // replace localStorage hashes with new hashes - const stored: string | null = localStorage.getItem(this.hashSetStorageKey); - let old: string[] = []; - if (stored != null) { - old = stored.split(','); - } - - const newHashes = items.map((i) => i.hash); - const updatedHashes = [...new Set(old.concat(newHashes))]; - localStorage.setItem(this.hashSetStorageKey, updatedHashes.join(',')); - - return { previous: old, current: updatedHashes }; - } - - fetchNewsfeedItems(http: HttpSetup, config: ApiConfig): Rx.Observable { - const urlPath = config.pathTemplate.replace('{VERSION}', this.kibanaVersion); - const fullUrl = (config.urlRoot || NEWSFEED_DEFAULT_SERVICE_BASE_URL) + urlPath; - - return Rx.from( - http - .fetch(fullUrl, { - method: 'GET', - }) - .then(({ items }: { items: ApiItem[] }) => { - return this.modelItems(items); - }) - ); - } - - validateItem(item: Partial) { - const hasMissing = [ - item.title, - item.description, - item.linkText, - item.linkUrl, - item.publishOn, - item.hash, - ].includes(undefined); - - return !hasMissing; - } - - modelItems(items: ApiItem[]): FetchResult { - const feedItems: NewsfeedItem[] = items.reduce((accum: NewsfeedItem[], it: ApiItem) => { - let chosenLanguage = this.userLanguage; - const { - expire_on: expireOnUtc, - publish_on: publishOnUtc, - languages, - title, - description, - link_text: linkText, - link_url: linkUrl, - badge, - hash, - } = it; - - if (moment(expireOnUtc).isBefore(Date.now())) { - return accum; // ignore item if expired - } - - if (moment(publishOnUtc).isAfter(Date.now())) { - return accum; // ignore item if publish date hasn't occurred yet (pre-published) - } - - if (languages && !languages.includes(chosenLanguage)) { - chosenLanguage = NEWSFEED_FALLBACK_LANGUAGE; // don't remove the item: fallback on a language - } - - const tempItem: NewsfeedItem = { - title: title[chosenLanguage], - description: description[chosenLanguage], - linkText: linkText != null ? linkText[chosenLanguage] : null, - linkUrl: linkUrl[chosenLanguage], - badge: badge != null ? badge![chosenLanguage] : null, - publishOn: moment(publishOnUtc), - expireOn: moment(expireOnUtc), - hash: hash.slice(0, 10), // optimize for storage and faster parsing - }; - - if (!this.validateItem(tempItem)) { - return accum; // ignore if title, description, etc is missing - } - - return [...accum, tempItem]; - }, []); - - // calculate hasNew - const { previous, current } = this.updateHashes(feedItems); - const hasNew = current.length > previous.length; - - return { - error: null, - kibanaVersion: this.kibanaVersion, - hasNew, - feedItems, - }; - } +export interface NewsfeedApi { + /** + * The current fetch results + */ + fetchResults$: Observable; + + /** + * Mark the given items as read. + * Will refresh the `hasNew` value of the emitted FetchResult accordingly + */ + markAsRead(itemHashes: string[]): void; } /* @@ -175,22 +38,23 @@ export class NewsfeedApiDriver { * Computes hasNew value from new item hashes saved in localStorage */ export function getApi( - http: HttpSetup, config: NewsfeedPluginBrowserConfig, - kibanaVersion: string -): Rx.Observable { + kibanaVersion: string, + newsfeedId: string +): NewsfeedApi { const userLanguage = i18n.getLocale(); const fetchInterval = config.fetchInterval.asMilliseconds(); const mainInterval = config.mainInterval.asMilliseconds(); - const driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval); + const storage = new NewsfeedStorage(newsfeedId); + const driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval, storage); - return Rx.timer(0, mainInterval).pipe( + const results$ = timer(0, mainInterval).pipe( filter(() => driver.shouldFetch()), mergeMap(() => - driver.fetchNewsfeedItems(http, config.service).pipe( + driver.fetchNewsfeedItems(config.service).pipe( catchError((err) => { window.console.error(err); - return Rx.of({ + return of({ error: err, kibanaVersion, hasNew: false, @@ -199,6 +63,22 @@ export function getApi( }) ) ), - tap(() => driver.updateLastFetch()) + tap(() => storage.setLastFetchTime(new Date())) ); + + const merged$ = combineLatest([results$, storage.isAnyUnread$()]).pipe( + map(([results, isAnyUnread]) => { + return { + ...results, + hasNew: results.error ? false : isAnyUnread, + }; + }) + ); + + return { + fetchResults$: merged$, + markAsRead: (itemHashes) => { + storage.markItemsAsRead(itemHashes); + }, + }; } diff --git a/src/plugins/newsfeed/public/lib/convert_items.test.ts b/src/plugins/newsfeed/public/lib/convert_items.test.ts new file mode 100644 index 0000000000000..8b599d841935c --- /dev/null +++ b/src/plugins/newsfeed/public/lib/convert_items.test.ts @@ -0,0 +1,144 @@ +/* + * 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 { omit } from 'lodash'; +import { validateIntegrity, validatePublishedDate, localizeItem } from './convert_items'; +import type { ApiItem, NewsfeedItem } from '../types'; + +const createApiItem = (parts: Partial = {}): ApiItem => ({ + hash: 'hash', + expire_on: new Date(), + publish_on: new Date(), + title: {}, + description: {}, + link_url: {}, + ...parts, +}); + +const createNewsfeedItem = (parts: Partial = {}): NewsfeedItem => ({ + title: 'title', + description: 'description', + linkText: 'linkText', + linkUrl: 'linkUrl', + badge: 'badge', + publishOn: moment(), + expireOn: moment(), + hash: 'hash', + ...parts, +}); + +describe('localizeItem', () => { + const item = createApiItem({ + languages: ['en', 'fr'], + title: { + en: 'en title', + fr: 'fr title', + }, + description: { + en: 'en desc', + fr: 'fr desc', + }, + link_text: { + en: 'en link text', + fr: 'fr link text', + }, + link_url: { + en: 'en link url', + fr: 'fr link url', + }, + badge: { + en: 'en badge', + fr: 'fr badge', + }, + publish_on: new Date('2014-10-31T04:23:47Z'), + expire_on: new Date('2049-10-31T04:23:47Z'), + hash: 'hash', + }); + + it('converts api items to newsfeed items using the specified language', () => { + expect(localizeItem(item, 'fr')).toMatchObject({ + title: 'fr title', + description: 'fr desc', + linkText: 'fr link text', + linkUrl: 'fr link url', + badge: 'fr badge', + hash: 'hash', + }); + }); + + it('fallbacks to `en` is the language is not present', () => { + expect(localizeItem(item, 'de')).toMatchObject({ + title: 'en title', + description: 'en desc', + linkText: 'en link text', + linkUrl: 'en link url', + badge: 'en badge', + hash: 'hash', + }); + }); +}); + +describe('validatePublishedDate', () => { + it('returns false when the publish date is not reached yet', () => { + expect( + validatePublishedDate( + createApiItem({ + publish_on: new Date('2055-10-31T04:23:47Z'), // too new + expire_on: new Date('2056-10-31T04:23:47Z'), + }) + ) + ).toBe(false); + }); + + it('returns false when the expire date is already reached', () => { + expect( + validatePublishedDate( + createApiItem({ + publish_on: new Date('2013-10-31T04:23:47Z'), + expire_on: new Date('2014-10-31T04:23:47Z'), // too old + }) + ) + ).toBe(false); + }); + + it('returns true when current date is between the publish and expire dates', () => { + expect( + validatePublishedDate( + createApiItem({ + publish_on: new Date('2014-10-31T04:23:47Z'), + expire_on: new Date('2049-10-31T04:23:47Z'), + }) + ) + ).toBe(true); + }); +}); + +describe('validateIntegrity', () => { + it('returns false if `title` is missing', () => { + expect(validateIntegrity(omit(createNewsfeedItem(), 'title'))).toBe(false); + }); + it('returns false if `description` is missing', () => { + expect(validateIntegrity(omit(createNewsfeedItem(), 'description'))).toBe(false); + }); + it('returns false if `linkText` is missing', () => { + expect(validateIntegrity(omit(createNewsfeedItem(), 'linkText'))).toBe(false); + }); + it('returns false if `linkUrl` is missing', () => { + expect(validateIntegrity(omit(createNewsfeedItem(), 'linkUrl'))).toBe(false); + }); + it('returns false if `publishOn` is missing', () => { + expect(validateIntegrity(omit(createNewsfeedItem(), 'publishOn'))).toBe(false); + }); + it('returns false if `hash` is missing', () => { + expect(validateIntegrity(omit(createNewsfeedItem(), 'hash'))).toBe(false); + }); + it('returns true if all mandatory fields are present', () => { + expect(validateIntegrity(createNewsfeedItem())).toBe(true); + }); +}); diff --git a/src/plugins/newsfeed/public/lib/convert_items.ts b/src/plugins/newsfeed/public/lib/convert_items.ts new file mode 100644 index 0000000000000..38ea2cc895f3e --- /dev/null +++ b/src/plugins/newsfeed/public/lib/convert_items.ts @@ -0,0 +1,72 @@ +/* + * 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 { ApiItem, NewsfeedItem } from '../types'; +import { NEWSFEED_FALLBACK_LANGUAGE } from '../../common/constants'; + +export const convertItems = (items: ApiItem[], userLanguage: string): NewsfeedItem[] => { + return items + .filter(validatePublishedDate) + .map((item) => localizeItem(item, userLanguage)) + .filter(validateIntegrity); +}; + +export const validatePublishedDate = (item: ApiItem): boolean => { + if (moment(item.expire_on).isBefore(Date.now())) { + return false; // ignore item if expired + } + + if (moment(item.publish_on).isAfter(Date.now())) { + return false; // ignore item if publish date hasn't occurred yet (pre-published) + } + return true; +}; + +export const localizeItem = (rawItem: ApiItem, userLanguage: string): NewsfeedItem => { + const { + expire_on: expireOnUtc, + publish_on: publishOnUtc, + languages, + title, + description, + link_text: linkText, + link_url: linkUrl, + badge, + hash, + } = rawItem; + + let chosenLanguage = userLanguage; + if (languages && !languages.includes(chosenLanguage)) { + chosenLanguage = NEWSFEED_FALLBACK_LANGUAGE; // don't remove the item: fallback on a language + } + + return { + title: title[chosenLanguage], + description: description[chosenLanguage], + linkText: linkText != null ? linkText[chosenLanguage] : null, + linkUrl: linkUrl[chosenLanguage], + badge: badge != null ? badge![chosenLanguage] : null, + publishOn: moment(publishOnUtc), + expireOn: moment(expireOnUtc), + hash: hash.slice(0, 10), // optimize for storage and faster parsing + }; +}; + +export const validateIntegrity = (item: Partial): boolean => { + const hasMissing = [ + item.title, + item.description, + item.linkText, + item.linkUrl, + item.publishOn, + item.hash, + ].includes(undefined); + + return !hasMissing; +}; diff --git a/src/plugins/newsfeed/public/lib/driver.mock.ts b/src/plugins/newsfeed/public/lib/driver.mock.ts new file mode 100644 index 0000000000000..8ae4ad1a82c4d --- /dev/null +++ b/src/plugins/newsfeed/public/lib/driver.mock.ts @@ -0,0 +1,22 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import type { NewsfeedApiDriver } from './driver'; + +const createDriverMock = () => { + const mock: jest.Mocked> = { + shouldFetch: jest.fn(), + fetchNewsfeedItems: jest.fn(), + }; + return mock as jest.Mocked; +}; + +export const driverMock = { + create: createDriverMock, +}; diff --git a/src/plugins/newsfeed/public/lib/driver.test.mocks.ts b/src/plugins/newsfeed/public/lib/driver.test.mocks.ts new file mode 100644 index 0000000000000..2d7123ebc2d1f --- /dev/null +++ b/src/plugins/newsfeed/public/lib/driver.test.mocks.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export const convertItemsMock = jest.fn(); +jest.doMock('./convert_items', () => ({ + convertItems: convertItemsMock, +})); diff --git a/src/plugins/newsfeed/public/lib/driver.test.ts b/src/plugins/newsfeed/public/lib/driver.test.ts new file mode 100644 index 0000000000000..38ec90cf20101 --- /dev/null +++ b/src/plugins/newsfeed/public/lib/driver.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { convertItemsMock } from './driver.test.mocks'; +// @ts-expect-error +import fetchMock from 'fetch-mock/es5/client'; +import { take } from 'rxjs/operators'; +import { NewsfeedApiDriver } from './driver'; +import { storageMock } from './storage.mock'; + +const kibanaVersion = '8.0.0'; +const userLanguage = 'en'; +const fetchInterval = 2000; + +describe('NewsfeedApiDriver', () => { + let driver: NewsfeedApiDriver; + let storage: ReturnType; + + beforeEach(() => { + storage = storageMock.create(); + driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval, storage); + convertItemsMock.mockReturnValue([]); + }); + + afterEach(() => { + fetchMock.reset(); + convertItemsMock.mockReset(); + }); + + afterAll(() => { + fetchMock.restore(); + }); + + describe('shouldFetch', () => { + it('returns true if no value is present in the storage', () => { + storage.getLastFetchTime.mockReturnValue(undefined); + expect(driver.shouldFetch()).toBe(true); + expect(storage.getLastFetchTime).toHaveBeenCalledTimes(1); + }); + + it('returns true if last fetch time precedes page load time', () => { + storage.getLastFetchTime.mockReturnValue(new Date(Date.now() - 456789)); + expect(driver.shouldFetch()).toBe(true); + }); + + it('returns false if last fetch time is recent enough', () => { + storage.getLastFetchTime.mockReturnValue(new Date(Date.now() + 745678)); + expect(driver.shouldFetch()).toBe(false); + }); + }); + + describe('fetchNewsfeedItems', () => { + it('calls `window.fetch` with the correct parameters', async () => { + fetchMock.get('*', { items: [] }); + await driver + .fetchNewsfeedItems({ + urlRoot: 'http://newsfeed.com', + pathTemplate: '/{VERSION}/news', + }) + .pipe(take(1)) + .toPromise(); + + expect(fetchMock.lastUrl()).toEqual('http://newsfeed.com/8.0.0/news'); + expect(fetchMock.lastOptions()).toEqual({ + method: 'GET', + }); + }); + + it('calls `convertItems` with the correct parameters', async () => { + fetchMock.get('*', { items: ['foo', 'bar'] }); + + await driver + .fetchNewsfeedItems({ + urlRoot: 'http://newsfeed.com', + pathTemplate: '/{VERSION}/news', + }) + .pipe(take(1)) + .toPromise(); + + expect(convertItemsMock).toHaveBeenCalledTimes(1); + expect(convertItemsMock).toHaveBeenCalledWith(['foo', 'bar'], userLanguage); + }); + + it('calls `storage.setFetchedItems` with the correct parameters', async () => { + fetchMock.get('*', { items: [] }); + convertItemsMock.mockReturnValue([ + { id: '1', hash: 'hash1' }, + { id: '2', hash: 'hash2' }, + ]); + + await driver + .fetchNewsfeedItems({ + urlRoot: 'http://newsfeed.com', + pathTemplate: '/{VERSION}/news', + }) + .pipe(take(1)) + .toPromise(); + + expect(storage.setFetchedItems).toHaveBeenCalledTimes(1); + expect(storage.setFetchedItems).toHaveBeenCalledWith(['hash1', 'hash2']); + }); + + it('returns the expected values', async () => { + fetchMock.get('*', { items: [] }); + const feedItems = [ + { id: '1', hash: 'hash1' }, + { id: '2', hash: 'hash2' }, + ]; + convertItemsMock.mockReturnValue(feedItems); + storage.setFetchedItems.mockReturnValue(true); + + const result = await driver + .fetchNewsfeedItems({ + urlRoot: 'http://newsfeed.com', + pathTemplate: '/{VERSION}/news', + }) + .pipe(take(1)) + .toPromise(); + + expect(result).toEqual({ + error: null, + kibanaVersion, + hasNew: true, + feedItems, + }); + }); + }); +}); diff --git a/src/plugins/newsfeed/public/lib/driver.ts b/src/plugins/newsfeed/public/lib/driver.ts new file mode 100644 index 0000000000000..0efa981e8c89d --- /dev/null +++ b/src/plugins/newsfeed/public/lib/driver.ts @@ -0,0 +1,79 @@ +/* + * 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 * as Rx from 'rxjs'; +import { NEWSFEED_DEFAULT_SERVICE_BASE_URL } from '../../common/constants'; +import { ApiItem, FetchResult, NewsfeedPluginBrowserConfig } from '../types'; +import { convertItems } from './convert_items'; +import type { NewsfeedStorage } from './storage'; + +type ApiConfig = NewsfeedPluginBrowserConfig['service']; + +interface NewsfeedResponse { + items: ApiItem[]; +} + +export class NewsfeedApiDriver { + private readonly kibanaVersion: string; + private readonly loadedTime = moment().utc(); // the date is compared to time in UTC format coming from the service + + constructor( + kibanaVersion: string, + private readonly userLanguage: string, + private readonly fetchInterval: number, + private readonly storage: NewsfeedStorage + ) { + // The API only accepts versions in the format `X.Y.Z`, so we need to drop the `-SNAPSHOT` or any other label after it + this.kibanaVersion = kibanaVersion.replace(/^(\d+\.\d+\.\d+).*/, '$1'); + } + + shouldFetch(): boolean { + const lastFetchUtc = this.storage.getLastFetchTime(); + if (!lastFetchUtc) { + return true; + } + const last = moment(lastFetchUtc); + + // does the last fetch time precede the time that the page was loaded? + if (this.loadedTime.diff(last) > 0) { + return true; + } + + const now = moment.utc(); // always use UTC to compare timestamps that came from the service + const duration = moment.duration(now.diff(last)); + return duration.asMilliseconds() > this.fetchInterval; + } + + fetchNewsfeedItems(config: ApiConfig): Rx.Observable { + const urlPath = config.pathTemplate.replace('{VERSION}', this.kibanaVersion); + const fullUrl = (config.urlRoot || NEWSFEED_DEFAULT_SERVICE_BASE_URL) + urlPath; + const request = new Request(fullUrl, { + method: 'GET', + }); + + return Rx.from( + window.fetch(request).then(async (response) => { + const { items } = (await response.json()) as NewsfeedResponse; + return this.convertResponse(items); + }) + ); + } + + private convertResponse(items: ApiItem[]): FetchResult { + const feedItems = convertItems(items, this.userLanguage); + const hasNew = this.storage.setFetchedItems(feedItems.map((item) => item.hash)); + + return { + error: null, + kibanaVersion: this.kibanaVersion, + hasNew, + feedItems, + }; + } +} diff --git a/src/plugins/newsfeed/public/lib/storage.mock.ts b/src/plugins/newsfeed/public/lib/storage.mock.ts new file mode 100644 index 0000000000000..98681e20b0665 --- /dev/null +++ b/src/plugins/newsfeed/public/lib/storage.mock.ts @@ -0,0 +1,26 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import type { NewsfeedStorage } from './storage'; + +const createStorageMock = () => { + const mock: jest.Mocked> = { + getLastFetchTime: jest.fn(), + setLastFetchTime: jest.fn(), + setFetchedItems: jest.fn(), + markItemsAsRead: jest.fn(), + isAnyUnread: jest.fn(), + isAnyUnread$: jest.fn(), + }; + return mock as jest.Mocked; +}; + +export const storageMock = { + create: createStorageMock, +}; diff --git a/src/plugins/newsfeed/public/lib/storage.test.ts b/src/plugins/newsfeed/public/lib/storage.test.ts new file mode 100644 index 0000000000000..1c424d8247e86 --- /dev/null +++ b/src/plugins/newsfeed/public/lib/storage.test.ts @@ -0,0 +1,165 @@ +/* + * 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 { NewsfeedStorage, getStorageKey } from './storage'; +import { take } from 'rxjs/operators'; + +describe('NewsfeedStorage', () => { + const storagePrefix = 'test'; + let mockStorage: Record; + let storage: NewsfeedStorage; + + const getKey = (key: string) => getStorageKey(storagePrefix, key); + + beforeAll(() => { + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (key: string) => { + return mockStorage[key] || null; + }, + setItem: (key: string, value: string) => { + mockStorage[key] = value; + }, + }, + writable: true, + }); + }); + + afterAll(() => { + delete (window as any).localStorage; + }); + + beforeEach(() => { + mockStorage = {}; + storage = new NewsfeedStorage(storagePrefix); + }); + + describe('getLastFetchTime', () => { + it('returns undefined if not set', () => { + expect(storage.getLastFetchTime()).toBeUndefined(); + }); + + it('returns the last value that was set', () => { + const date = new Date(); + storage.setLastFetchTime(date); + expect(storage.getLastFetchTime()!.getTime()).toEqual(date.getTime()); + }); + }); + + describe('setFetchedItems', () => { + it('updates the value in the storage', () => { + storage.setFetchedItems(['a', 'b', 'c']); + expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({ + a: false, + b: false, + c: false, + }); + }); + + it('preserves the read status if present', () => { + const initialValue = { a: true, b: false }; + window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue)); + storage.setFetchedItems(['a', 'b', 'c']); + expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({ + a: true, + b: false, + c: false, + }); + }); + + it('removes the old keys from the storage', () => { + const initialValue = { a: true, b: false, old: false }; + window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue)); + storage.setFetchedItems(['a', 'b', 'c']); + expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({ + a: true, + b: false, + c: false, + }); + }); + }); + + describe('markItemsAsRead', () => { + it('flags the entries as read', () => { + const initialValue = { a: true, b: false, c: false }; + window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue)); + storage.markItemsAsRead(['b']); + expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({ + a: true, + b: true, + c: false, + }); + }); + + it('add the entries when not present', () => { + const initialValue = { a: true, b: false, c: false }; + window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue)); + storage.markItemsAsRead(['b', 'new']); + expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({ + a: true, + b: true, + c: false, + new: true, + }); + }); + }); + + describe('isAnyUnread', () => { + it('returns true if any item was not read', () => { + storage.setFetchedItems(['a', 'b', 'c']); + storage.markItemsAsRead(['a']); + expect(storage.isAnyUnread()).toBe(true); + }); + + it('returns true if all item are unread', () => { + storage.setFetchedItems(['a', 'b', 'c']); + expect(storage.isAnyUnread()).toBe(true); + }); + + it('returns false if all item are unread', () => { + storage.setFetchedItems(['a', 'b', 'c']); + storage.markItemsAsRead(['a', 'b', 'c']); + expect(storage.isAnyUnread()).toBe(false); + }); + + it('loads the value initially present in localStorage', () => { + const initialValue = { a: true, b: false }; + window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue)); + storage = new NewsfeedStorage(storagePrefix); + expect(storage.isAnyUnread()).toBe(true); + }); + }); + + describe('isAnyUnread$', () => { + it('emits an initial value at subscription', async () => { + const initialValue = { a: true, b: false, c: false }; + window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue)); + storage = new NewsfeedStorage(storagePrefix); + + expect(await storage.isAnyUnread$().pipe(take(1)).toPromise()).toBe(true); + }); + + it('emits when `setFetchedItems` is called', () => { + const emissions: boolean[] = []; + storage.isAnyUnread$().subscribe((unread) => emissions.push(unread)); + + storage.setFetchedItems(['a', 'b', 'c']); + expect(emissions).toEqual([false, true]); + }); + + it('emits when `markItemsAsRead` is called', () => { + const emissions: boolean[] = []; + storage.isAnyUnread$().subscribe((unread) => emissions.push(unread)); + + storage.setFetchedItems(['a', 'b', 'c']); + storage.markItemsAsRead(['a', 'b']); + storage.markItemsAsRead(['c']); + expect(emissions).toEqual([false, true, true, false]); + }); + }); +}); diff --git a/src/plugins/newsfeed/public/lib/storage.ts b/src/plugins/newsfeed/public/lib/storage.ts new file mode 100644 index 0000000000000..f3df242ad9423 --- /dev/null +++ b/src/plugins/newsfeed/public/lib/storage.ts @@ -0,0 +1,89 @@ +/* + * 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 { Observable, BehaviorSubject } from 'rxjs'; + +/** + * Persistence layer for the newsfeed driver + */ +export class NewsfeedStorage { + private readonly lastFetchStorageKey: string; + private readonly readStatusStorageKey: string; + private readonly unreadStatus$: BehaviorSubject; + + constructor(storagePrefix: string) { + this.lastFetchStorageKey = getStorageKey(storagePrefix, 'lastFetch'); + this.readStatusStorageKey = getStorageKey(storagePrefix, 'readStatus'); + this.unreadStatus$ = new BehaviorSubject(anyUnread(this.getReadStatus())); + } + + getLastFetchTime(): Date | undefined { + const lastFetchUtc = localStorage.getItem(this.lastFetchStorageKey); + if (!lastFetchUtc) { + return undefined; + } + + return moment(lastFetchUtc, 'x').toDate(); // parse as unix ms timestamp (already is UTC) + } + + setLastFetchTime(date: Date) { + localStorage.setItem(this.lastFetchStorageKey, JSON.stringify(date.getTime())); + } + + setFetchedItems(itemHashes: string[]): boolean { + const currentReadStatus = this.getReadStatus(); + + const newReadStatus: Record = {}; + itemHashes.forEach((hash) => { + newReadStatus[hash] = currentReadStatus[hash] ?? false; + }); + + return this.setReadStatus(newReadStatus); + } + + /** + * Marks given items as read, and return the overall unread status. + */ + markItemsAsRead(itemHashes: string[]): boolean { + const updatedReadStatus = this.getReadStatus(); + itemHashes.forEach((hash) => { + updatedReadStatus[hash] = true; + }); + return this.setReadStatus(updatedReadStatus); + } + + isAnyUnread(): boolean { + return this.unreadStatus$.value; + } + + isAnyUnread$(): Observable { + return this.unreadStatus$.asObservable(); + } + + private getReadStatus(): Record { + try { + return JSON.parse(localStorage.getItem(this.readStatusStorageKey) || '{}'); + } catch (e) { + return {}; + } + } + + private setReadStatus(status: Record) { + const hasUnread = anyUnread(status); + this.unreadStatus$.next(anyUnread(status)); + localStorage.setItem(this.readStatusStorageKey, JSON.stringify(status)); + return hasUnread; + } +} + +const anyUnread = (status: Record): boolean => + Object.values(status).some((read) => !read); + +/** @internal */ +export const getStorageKey = (prefix: string, key: string) => `newsfeed.${prefix}.${key}`; diff --git a/src/plugins/newsfeed/public/plugin.tsx b/src/plugins/newsfeed/public/plugin.tsx index a788b3c4d0b59..fdda0a24b8bd5 100644 --- a/src/plugins/newsfeed/public/plugin.tsx +++ b/src/plugins/newsfeed/public/plugin.tsx @@ -7,15 +7,15 @@ */ import * as Rx from 'rxjs'; -import { catchError, takeUntil, share } from 'rxjs/operators'; +import { catchError, takeUntil } from 'rxjs/operators'; import ReactDOM from 'react-dom'; import React from 'react'; import moment from 'moment'; import { I18nProvider } from '@kbn/i18n/react'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { NewsfeedPluginBrowserConfig, FetchResult } from './types'; -import { NewsfeedNavButton, NewsfeedApiFetchResult } from './components/newsfeed_header_nav_button'; -import { getApi, NewsfeedApiEndpoint } from './lib/api'; +import { NewsfeedPluginBrowserConfig } from './types'; +import { NewsfeedNavButton } from './components/newsfeed_header_nav_button'; +import { getApi, NewsfeedApi, NewsfeedApiEndpoint } from './lib/api'; export type NewsfeedPublicPluginSetup = ReturnType; export type NewsfeedPublicPluginStart = ReturnType; @@ -42,10 +42,10 @@ export class NewsfeedPublicPlugin } public start(core: CoreStart) { - const api$ = this.fetchNewsfeed(core, this.config).pipe(share()); + const api = this.createNewsfeedApi(this.config, NewsfeedApiEndpoint.KIBANA); core.chrome.navControls.registerRight({ order: 1000, - mount: (target) => this.mount(api$, target), + mount: (target) => this.mount(api, target), }); return { @@ -56,7 +56,8 @@ export class NewsfeedPublicPlugin pathTemplate: `/${endpoint}/v{VERSION}.json`, }, }); - return this.fetchNewsfeed(core, config); + const { fetchResults$ } = this.createNewsfeedApi(config, endpoint); + return fetchResults$; }, }; } @@ -65,21 +66,24 @@ export class NewsfeedPublicPlugin this.stop$.next(); } - private fetchNewsfeed( - core: CoreStart, - config: NewsfeedPluginBrowserConfig - ): Rx.Observable { - const { http } = core; - return getApi(http, config, this.kibanaVersion).pipe( - takeUntil(this.stop$), // stop the interval when stop method is called - catchError(() => Rx.of(null)) // do not throw error - ); + private createNewsfeedApi( + config: NewsfeedPluginBrowserConfig, + newsfeedId: NewsfeedApiEndpoint + ): NewsfeedApi { + const api = getApi(config, this.kibanaVersion, newsfeedId); + return { + markAsRead: api.markAsRead, + fetchResults$: api.fetchResults$.pipe( + takeUntil(this.stop$), // stop the interval when stop method is called + catchError(() => Rx.of(null)) // do not throw error + ), + }; } - private mount(api$: NewsfeedApiFetchResult, targetDomElement: HTMLElement) { + private mount(api: NewsfeedApi, targetDomElement: HTMLElement) { ReactDOM.render( - + , targetDomElement ); 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 fcd97de56fc65..55db880a83932 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/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js index 2877373ffba9a..16e7b9d6072cb 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js @@ -7,11 +7,11 @@ */ import { getBucketsPath } from './get_buckets_path'; -import { parseInterval } from './parse_interval'; import { set } from '@elastic/safer-lodash-set'; import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { MODEL_SCRIPTS } from './moving_fn_scripts'; +import { convertIntervalToUnit } from './unit_to_seconds'; function checkMetric(metric, fields) { fields.forEach((field) => { @@ -161,19 +161,24 @@ export const bucketTransform = { }; }, - derivative: (bucket, metrics, bucketSize) => { + derivative: (bucket, metrics, intervalString) => { checkMetric(bucket, ['type', 'field']); + const body = { derivative: { buckets_path: getBucketsPath(bucket.field, metrics), gap_policy: 'skip', // seems sane - unit: bucketSize, + unit: intervalString, }, }; + if (bucket.gap_policy) body.derivative.gap_policy = bucket.gap_policy; if (bucket.unit) { - body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) ? bucket.unit : bucketSize; + body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) + ? bucket.unit + : intervalString; } + return body; }, @@ -214,8 +219,10 @@ export const bucketTransform = { }; }, - calculation: (bucket, metrics, bucketSize) => { + calculation: (bucket, metrics, intervalString) => { checkMetric(bucket, ['variables', 'script']); + const inMsInterval = convertIntervalToUnit(intervalString, 'ms'); + const body = { bucket_script: { buckets_path: bucket.variables.reduce((acc, row) => { @@ -226,7 +233,7 @@ export const bucketTransform = { source: bucket.script, lang: 'painless', params: { - _interval: parseInterval(bucketSize).asMilliseconds(), + _interval: inMsInterval?.value, }, }, gap_policy: 'skip', // seems sane diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index f82f332df19fd..253612c0274ad 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -29,17 +29,20 @@ export function dateHistogram( const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, seriesIndex); - const { bucketSize, intervalString } = getBucketSize( - req, - interval, - capabilities, - maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings - ); + const { from, to } = offsetTime(req, series.offset_time); - const getDateHistogramForLastBucketMode = () => { - const { from, to } = offsetTime(req, series.offset_time); + let bucketInterval; + + const overwriteDateHistogramForLastBucketMode = () => { const { timezone } = capabilities; + const { intervalString } = getBucketSize( + req, + interval, + capabilities, + maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings + ); + overwrite(doc, `aggs.${series.id}.aggs.timeseries.date_histogram`, { field: timeField, min_doc_count: 0, @@ -50,25 +53,29 @@ export function dateHistogram( }, ...dateHistogramInterval(intervalString), }); + + bucketInterval = intervalString; }; - const getDateHistogramForEntireTimerangeMode = () => + const overwriteDateHistogramForEntireTimerangeMode = () => { overwrite(doc, `aggs.${series.id}.aggs.timeseries.auto_date_histogram`, { field: timeField, buckets: 1, }); + bucketInterval = `${to.valueOf() - from.valueOf()}ms`; + }; + isLastValueTimerangeMode(panel, series) - ? getDateHistogramForLastBucketMode() - : getDateHistogramForEntireTimerangeMode(); + ? overwriteDateHistogramForLastBucketMode() + : overwriteDateHistogramForEntireTimerangeMode(); overwrite(doc, `aggs.${series.id}.meta`, { timeField, - intervalString, - bucketSize, + panelId: panel.id, seriesId: series.id, + intervalString: bucketInterval, index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, - panelId: panel.id, }); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index 741eb93267f4c..2cd7a213b273e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -86,7 +86,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 10, intervalString: '10s', timeField: '@timestamp', seriesId: 'test', @@ -128,7 +127,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 10, intervalString: '10s', timeField: '@timestamp', seriesId: 'test', @@ -173,7 +171,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 20, intervalString: '20s', timeField: 'timestamp', seriesId: 'test', @@ -185,8 +182,11 @@ describe('dateHistogram(req, panel, series)', () => { }); describe('dateHistogram for entire time range mode', () => { - test('should ignore entire range mode for timeseries', async () => { + beforeEach(() => { panel.time_range_mode = 'entire_time_range'; + }); + + test('should ignore entire range mode for timeseries', async () => { panel.type = 'timeseries'; const next = (doc) => doc; @@ -204,9 +204,36 @@ describe('dateHistogram(req, panel, series)', () => { expect(doc.aggs.test.aggs.timeseries.date_histogram).toBeDefined(); }); - test('should returns valid date histogram for entire range mode', async () => { - panel.time_range_mode = 'entire_time_range'; + test('should set meta values', async () => { + // set 15 minutes (=== 900000ms) interval; + req.body.timerange = { + min: '2021-01-01T00:00:00Z', + max: '2021-01-01T00:15:00Z', + }; + + const next = (doc) => doc; + const doc = await dateHistogram( + req, + panel, + series, + config, + indexPattern, + capabilities, + uiSettings + )(next)({}); + expect(doc.aggs.test.meta).toMatchInlineSnapshot(` + Object { + "index": undefined, + "intervalString": "900000ms", + "panelId": "panelId", + "seriesId": "test", + "timeField": "@timestamp", + } + `); + }); + + test('should returns valid date histogram for entire range mode', async () => { const next = (doc) => doc; const doc = await dateHistogram( req, @@ -232,8 +259,7 @@ describe('dateHistogram(req, panel, series)', () => { meta: { timeField: '@timestamp', seriesId: 'test', - bucketSize: 10, - intervalString: '10s', + intervalString: '3600000ms', panelId: 'panelId', }, }, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 29a11bf163e0b..33c6622f73941 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -7,30 +7,17 @@ */ import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -import { UI_SETTINGS } from '../../../../../../data/common'; +import { get } from 'lodash'; -export function metricBuckets( - req, - panel, - series, - esQueryConfig, - seriesIndex, - capabilities, - uiSettings -) { +export function metricBuckets(req, panel, series) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - - const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); - const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - series.metrics .filter((row) => !/_bucket$/.test(row.type) && !/^series/.test(row.type)) .forEach((metric) => { const fn = bucketTransform[metric.type]; + const intervalString = get(doc, `aggs.${series.id}.meta.intervalString`); + if (fn) { try { const bucket = fn(metric, series.metrics, intervalString); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index dbeb3b1393bd5..c3075dd6dcac0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -6,39 +6,28 @@ * Side Public License, v 1. */ +import { get } from 'lodash'; import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -import { UI_SETTINGS } from '../../../../../../data/common'; -export function siblingBuckets( - req, - panel, - series, - esQueryConfig, - seriesIndex, - capabilities, - uiSettings -) { +export function siblingBuckets(req, panel, series) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); - const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - series.metrics .filter((row) => /_bucket$/.test(row.type)) .forEach((metric) => { const fn = bucketTransform[metric.type]; + const intervalString = get(doc, `aggs.${series.id}.meta.intervalString`); + if (fn) { try { - const bucket = fn(metric, series.metrics, bucketSize); + const bucket = fn(metric, series.metrics, intervalString); overwrite(doc, `aggs.${series.id}.aggs.${metric.id}`, bucket); } catch (e) { // meh } } }); + return next(doc); }; } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 3e883abc9e5e0..92ac4078a3835 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -20,6 +20,7 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const { timeField, interval } = getIntervalAndTimefield(panel, {}, seriesIndex); + const { from, to } = getTimerange(req); const meta = { timeField, @@ -27,14 +28,8 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti panelId: panel.id, }; - const getDateHistogramForLastBucketMode = () => { - const { bucketSize, intervalString } = getBucketSize( - req, - interval, - capabilities, - barTargetUiSettings - ); - const { from, to } = getTimerange(req); + const overwriteDateHistogramForLastBucketMode = () => { + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); const { timezone } = capabilities; panel.series.forEach((column) => { @@ -54,12 +49,13 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { ...meta, intervalString, - bucketSize, }); }); }; - const getDateHistogramForEntireTimerangeMode = () => { + const overwriteDateHistogramForEntireTimerangeMode = () => { + const intervalString = `${to.valueOf() - from.valueOf()}ms`; + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); @@ -68,13 +64,16 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti buckets: 1, }); - overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), meta); + overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { + ...meta, + intervalString, + }); }); }; isLastValueTimerangeMode(panel) - ? getDateHistogramForLastBucketMode() - : getDateHistogramForEntireTimerangeMode(); + ? overwriteDateHistogramForLastBucketMode() + : overwriteDateHistogramForEntireTimerangeMode(); return next(doc); }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 421f9d2d75f0c..8e0d0060225ff 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -6,19 +6,13 @@ * Side Public License, v 1. */ +import { get } from 'lodash'; import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -import { UI_SETTINGS } from '../../../../../../data/common'; -export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { +export function metricBuckets(req, panel) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); - const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics @@ -27,7 +21,9 @@ export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabiliti const fn = bucketTransform[metric.type]; if (fn) { try { + const intervalString = get(doc, aggRoot.replace(/\.aggs$/, '.meta.intervalString')); const bucket = fn(metric, column.metrics, intervalString); + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, bucket); } catch (e) { // meh diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index 9b4b0f244fc2c..6ce956c490900 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -7,18 +7,12 @@ */ import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -import { UI_SETTINGS } from '../../../../../../data/common'; +import { get } from 'lodash'; -export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { +export function siblingBuckets(req, panel) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); - const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics @@ -27,7 +21,9 @@ export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilit const fn = bucketTransform[metric.type]; if (fn) { try { - const bucket = fn(metric, column.metrics, bucketSize); + const intervalString = get(doc, aggRoot.replace(/\.aggs$/, '.meta.intervalString')); + const bucket = fn(metric, column.metrics, intervalString); + overwrite(doc, `${aggRoot}.${metric.id}`, bucket); } catch (e) { // meh diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js index 403b486cc4d09..d3cff76524ee3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { convertIntervalToUnit } from '../../helpers/unit_to_seconds'; + const percentileValueMatch = /\[([0-9\.]+)\]$/; import { startsWith, flatten, values, first, last } from 'lodash'; import { getDefaultDecoration } from '../../helpers/get_default_decoration'; @@ -82,13 +84,15 @@ export function mathAgg(resp, panel, series, meta, extractFields) { if (someNull) return [ts, null]; try { // calculate the result based on the user's script and return the value + const inMsInterval = convertIntervalToUnit(split.meta?.intervalString || 0, 'ms'); + const result = evaluate(mathMetric.script, { params: { ...params, _index: index, _timestamp: ts, _all: all, - _interval: split.meta.bucketSize * 1000, + _interval: inMsInterval?.value, }, }); // if the result is an object (usually when the user is working with maps and functions) flatten the results and return the last value. diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js index 1e30720d6e5b2..7b5eb1e029069 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js @@ -54,7 +54,7 @@ describe('math(resp, panel, series)', () => { aggregations: { test: { meta: { - bucketSize: 5, + intervalString: '5s', }, buckets: [ { @@ -124,6 +124,25 @@ describe('math(resp, panel, series)', () => { ); }); + test('should works with predefined variables (params._interval)', async () => { + const expectedInterval = 5000; + + series.metrics[2].script = 'params._interval'; + + const next = await mathAgg(resp, panel, series)((results) => results); + const results = await stdMetric(resp, panel, series)(next)([]); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual( + expect.objectContaining({ + data: [ + [1, expectedInterval], + [2, expectedInterval], + ], + }) + ); + }); + test('throws on actual tinymath expression errors #1', async () => { series.metrics[2].script = 'notExistingFn(params.a)'; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 5b865d451003a..46acbb27e15e1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -153,7 +153,6 @@ describe('buildRequestBody(req)', () => { time_zone: 'UTC', }, meta: { - bucketSize: 10, intervalString: '10s', seriesId: 'c9b5f9c0-e403-11e6-be91-6f7688e9fac7', timeField: '@timestamp', diff --git a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js index cb8a8f72c5172..5e1f0bfbb4464 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js +++ b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js @@ -31,6 +31,7 @@ export function pointSeriesTooltipFormatter() { const details = []; const isGauge = config.get('gauge', false); + const chartType = config.get('type', undefined); const isPercentageMode = config.get(isGauge ? 'gauge.percentageMode' : 'percentageMode', false); const isSetColorRange = config.get('setColorRange', false); @@ -44,7 +45,8 @@ export function pointSeriesTooltipFormatter() { }); } - if (datum.x !== null && datum.x !== undefined) { + // For goal and gauge we have only one value for x - '_all'. It doesn't have sense to show it + if (datum.x !== null && datum.x !== undefined && !['goal', 'gauge'].includes(chartType)) { addDetail(data.xAxisLabel, data.xAxisFormatter(datum.x)); } diff --git a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js index 5c0548ea399b7..a207b1f4360b6 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js +++ b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js @@ -96,4 +96,27 @@ describe('tooltipFormatter', function () { const $rows = $el.find('tr'); expect($rows.length).toBe(3); }); + + it('renders correctly for gauge/goal visualizations', function () { + const event = _.cloneDeep(baseEvent); + let type = 'gauge'; + event.config.get = (name) => { + const config = { + setColorRange: false, + gauge: false, + percentageMode: false, + type, + }; + return config[name]; + }; + + let $el = $(tooltipFormatter(event, uiSettings)); + let $rows = $el.find('tr'); + expect($rows.length).toBe(2); + + type = 'goal'; + $el = $(tooltipFormatter(event, uiSettings)); + $rows = $el.find('tr'); + expect($rows.length).toBe(2); + }); }); 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 0000000000000..c750602f735bd --- /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 c33a87a93b903..18d20c97be81e 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/alerting/server/health/get_health.ts b/x-pack/plugins/alerting/server/health/get_health.ts index 4a0266c9b729f..6966c9b75ca43 100644 --- a/x-pack/plugins/alerting/server/health/get_health.ts +++ b/x-pack/plugins/alerting/server/health/get_health.ts @@ -34,6 +34,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (decryptErrorData.length > 0) { @@ -51,6 +52,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (executeErrorData.length > 0) { @@ -68,6 +70,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (readErrorData.length > 0) { @@ -83,6 +86,7 @@ export const getHealth = async ( type: 'alert', sortField: 'executionStatus.lastExecutionDate', sortOrder: 'desc', + namespaces: ['*'], }); const lastExecutionDate = noErrorData.length > 0 diff --git a/x-pack/plugins/cases/public/components/connectors/case/index.ts b/x-pack/plugins/cases/public/components/connectors/case/index.ts index c2cf4980da7ec..8e6680cd65387 100644 --- a/x-pack/plugins/cases/public/components/connectors/case/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/case/index.ts @@ -25,7 +25,7 @@ const validateParams = (actionParams: CaseActionParams) => { validationResult.errors.caseId.push(i18n.CASE_CONNECTOR_CASE_REQUIRED); } - return validationResult; + return Promise.resolve(validationResult); }; export function getActionType(): ActionTypeModel { @@ -34,7 +34,7 @@ export function getActionType(): ActionTypeModel { iconClass: 'securityAnalyticsApp', selectMessage: i18n.CASE_CONNECTOR_DESC, actionTypeTitle: i18n.CASE_CONNECTOR_TITLE, - validateConnector: () => ({ config: { errors: {} }, secrets: { errors: {} } }), + validateConnector: () => Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }), validateParams, actionConnectorFields: null, actionParamsFields: lazy(() => import('./alert_fields')), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/assets/bg_crawler_landing.png b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/assets/bg_crawler_landing.png new file mode 100644 index 0000000000000..e1f14ac26f353 Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/assets/bg_crawler_landing.png differ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/constants.ts index 0c1bf051747b5..7e95bf8fb8e45 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/constants.ts @@ -9,5 +9,5 @@ import { i18n } from '@kbn/i18n'; export const CRAWLER_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.crawler.title', - { defaultMessage: 'Crawler' } + { defaultMessage: 'Web Crawler' } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.scss new file mode 100644 index 0000000000000..3ace4064008b6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.scss @@ -0,0 +1,13 @@ +.crawlerLanding { + &__panel { + overflow: hidden; + background-image: url('./assets/bg_crawler_landing.png'); + background-size: 45%; + background-repeat: no-repeat; + background-position: right -2rem; + } + + &__wrapper { + max-width: 50rem; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx new file mode 100644 index 0000000000000..9591b82773b9f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { setMockValues } from '../../../__mocks__'; +import { mockEngineValues } from '../../__mocks__'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { docLinks } from '../../../shared/doc_links'; + +import { CrawlerLanding } from './crawler_landing'; + +describe('CrawlerLanding', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + setMockValues({ ...mockEngineValues }); + wrapper = shallow(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('contains an external documentation link', () => { + const externalDocumentationLink = wrapper.find('[data-test-subj="CrawlerDocumentationLink"]'); + + expect(externalDocumentationLink.prop('href')).toBe( + `${docLinks.appSearchBase}/web-crawler.html` + ); + }); + + it('contains a link to standalone App Search', () => { + const externalDocumentationLink = wrapper.find('[data-test-subj="CrawlerStandaloneLink"]'); + + expect(externalDocumentationLink.prop('href')).toBe('/as/engines/some-engine/crawler'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx new file mode 100644 index 0000000000000..a2993b4d86d5a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx @@ -0,0 +1,85 @@ +/* + * 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 React from 'react'; + +import { + EuiButton, + EuiLink, + EuiPageHeader, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { getAppSearchUrl } from '../../../shared/enterprise_search_url'; +import { DOCS_PREFIX, ENGINE_CRAWLER_PATH } from '../../routes'; +import { generateEnginePath } from '../engine'; + +import './crawler_landing.scss'; +import { CRAWLER_TITLE } from '.'; + +export const CrawlerLanding: React.FC = () => ( +
+ + + +
+ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.engine.crawler.landingPage.title', { + defaultMessage: 'Setup the Web Crawler', + })} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.crawler.landingPage.description', + { + defaultMessage: + "Easily index your website's content. To get started, enter your domain name, provide optional entry points and crawl rules, and we will handle the rest.", + } + )}{' '} + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.crawler.landingPage.documentationLinkLabel', + { + defaultMessage: 'Learn more about the web crawler.', + } + )} + +

+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.crawler.landingPage.standaloneLinkLabel', + { + defaultMessage: 'Configure the web crawler', + } + )} + + +
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx new file mode 100644 index 0000000000000..6aa9ca8c4feb1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { setMockValues } from '../../../__mocks__'; + +import { mockEngineValues } from '../../__mocks__'; + +import React from 'react'; +import { Switch } from 'react-router-dom'; + +import { shallow } from 'enzyme'; + +import { CrawlerLanding } from './crawler_landing'; +import { CrawlerRouter } from './crawler_router'; + +describe('CrawlerRouter', () => { + beforeEach(() => { + setMockValues({ ...mockEngineValues }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders a landing page', () => { + const wrapper = shallow(); + + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(CrawlerLanding)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx new file mode 100644 index 0000000000000..fcc949de7d8b4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx @@ -0,0 +1,27 @@ +/* + * 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 React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; + +import { getEngineBreadcrumbs } from '../engine'; + +import { CRAWLER_TITLE } from './constants'; +import { CrawlerLanding } from './crawler_landing'; + +export const CrawlerRouter: React.FC = () => { + return ( + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/index.ts index edb7e43aee35e..58fb0a7cebb1a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/index.ts @@ -6,3 +6,4 @@ */ export { CRAWLER_TITLE } from './constants'; +export { CrawlerRouter } from './crawler_router'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index 4738209cee4a2..0edf01bada938 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -12,7 +12,6 @@ import { useValues } from 'kea'; import { EuiText, EuiBadge, EuiIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { getAppSearchUrl } from '../../../shared/enterprise_search_url'; import { SideNavLink, SideNavItem } from '../../../shared/layout'; import { AppLogic } from '../../app_logic'; import { @@ -170,8 +169,7 @@ export const EngineNav: React.FC = () => { )} {canViewEngineCrawler && !isMetaEngine && ( {CRAWLER_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index 39055e772bcf9..3eab209d706fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -18,6 +18,7 @@ import { shallow } from 'enzyme'; import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; import { ApiLogs } from '../api_logs'; +import { CrawlerRouter } from '../crawler'; import { CurationsRouter } from '../curations'; import { Documents, DocumentDetail } from '../documents'; import { EngineOverview } from '../engine_overview'; @@ -168,4 +169,11 @@ describe('EngineRouter', () => { expect(wrapper.find(SourceEngines)).toHaveLength(1); }); + + it('renders a crawler view', () => { + setMockValues({ ...values, myRole: { canViewEngineCrawler: true } }); + const wrapper = shallow(); + + expect(wrapper.find(CrawlerRouter)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 387f8cf1b9837..40cc2ef0368c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -23,7 +23,7 @@ import { ENGINE_DOCUMENTS_PATH, ENGINE_DOCUMENT_DETAIL_PATH, ENGINE_SCHEMA_PATH, - // ENGINE_CRAWLER_PATH, + ENGINE_CRAWLER_PATH, META_ENGINE_SOURCE_ENGINES_PATH, ENGINE_RELEVANCE_TUNING_PATH, ENGINE_SYNONYMS_PATH, @@ -34,6 +34,7 @@ import { } from '../../routes'; import { AnalyticsRouter } from '../analytics'; import { ApiLogs } from '../api_logs'; +import { CrawlerRouter } from '../crawler'; import { CurationsRouter } from '../curations'; import { DocumentDetail, Documents } from '../documents'; import { EngineOverview } from '../engine_overview'; @@ -52,7 +53,7 @@ export const EngineRouter: React.FC = () => { canViewEngineAnalytics, canViewEngineDocuments, canViewEngineSchema, - // canViewEngineCrawler, + canViewEngineCrawler, canViewMetaEngineSourceEngines, canManageEngineRelevanceTuning, canManageEngineSynonyms, @@ -143,6 +144,11 @@ export const EngineRouter: React.FC = () => { )} + {canViewEngineCrawler && ( + + + + )} diff --git a/x-pack/plugins/file_data_visualizer/kibana.json b/x-pack/plugins/file_data_visualizer/kibana.json index 721352cff7c95..eea52bb6e98b2 100644 --- a/x-pack/plugins/file_data_visualizer/kibana.json +++ b/x-pack/plugins/file_data_visualizer/kibana.json @@ -14,7 +14,8 @@ ], "optionalPlugins": [ "security", - "maps" + "maps", + "home" ], "requiredBundles": [ "kibanaReact", diff --git a/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx b/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx index f291076557bb8..c8f327496842a 100644 --- a/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx @@ -28,3 +28,7 @@ export const FileDataVisualizer: FC = () => { ); }; + +// exporting as default so it can be used with React.lazy +// eslint-disable-next-line import/no-default-export +export default FileDataVisualizer; diff --git a/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/component_wrapper.tsx b/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/component_wrapper.tsx new file mode 100644 index 0000000000000..e6835d9e7a668 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/component_wrapper.tsx @@ -0,0 +1,18 @@ +/* + * 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 React, { FC } from 'react'; + +const FileDataVisualizerComponent = React.lazy(() => import('../application/file_datavisualizer')); + +export const FileDataVisualizerWrapper: FC = () => { + return ( + }> + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/plugin.ts b/x-pack/plugins/file_data_visualizer/public/plugin.ts index a94c0fce45cd4..0064f96195eaf 100644 --- a/x-pack/plugins/file_data_visualizer/public/plugin.ts +++ b/x-pack/plugins/file_data_visualizer/public/plugin.ts @@ -5,21 +5,24 @@ * 2.0. */ -import { CoreStart } from 'kibana/public'; +import { CoreSetup, CoreStart } from 'kibana/public'; import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { SharePluginStart } from '../../../../src/plugins/share/public'; import { Plugin } from '../../../../src/core/public'; import { setStartServices } from './kibana_services'; -import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import type { FileUploadPluginStart } from '../../file_upload/public'; import type { MapsStartApi } from '../../maps/public'; import type { SecurityPluginSetup } from '../../security/public'; import { getFileDataVisualizerComponent } from './api'; import { getMaxBytesFormatted } from './application/util/get_max_bytes'; +import { registerHomeAddData } from './register_home'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface FileDataVisualizerSetupDependencies {} +export interface FileDataVisualizerSetupDependencies { + home?: HomePublicPluginSetup; +} export interface FileDataVisualizerStartDependencies { data: DataPublicPluginStart; fileUpload: FileUploadPluginStart; @@ -40,7 +43,11 @@ export class FileDataVisualizerPlugin FileDataVisualizerSetupDependencies, FileDataVisualizerStartDependencies > { - public setup() {} + public setup(core: CoreSetup, plugins: FileDataVisualizerSetupDependencies) { + if (plugins.home) { + registerHomeAddData(plugins.home); + } + } public start(core: CoreStart, plugins: FileDataVisualizerStartDependencies) { setStartServices(core, plugins); diff --git a/x-pack/plugins/file_data_visualizer/public/register_home.ts b/x-pack/plugins/file_data_visualizer/public/register_home.ts new file mode 100644 index 0000000000000..e54c37a8d06bc --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/register_home.ts @@ -0,0 +1,20 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { FileDataVisualizerWrapper } from './lazy_load_bundle/component_wrapper'; + +export function registerHomeAddData(home: HomePublicPluginSetup) { + home.addData.registerAddDataTab({ + id: 'fileDataViz', + name: i18n.translate('xpack.fileDataVisualizer.embeddedTabTitle', { + defaultMessage: 'Upload file', + }), + component: FileDataVisualizerWrapper, + }); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx index 27bf5af72fb61..f441cfd951ba9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx @@ -66,3 +66,12 @@ test('it should allow to update existing host with multiple hosts', async () => fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } }); expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com', 'http://host2.com']); }); + +test('it should render an input if there is not hosts', async () => { + const { utils, mockOnChange } = renderInput([]); + + const inputEl = await utils.findByDisplayValue(''); + expect(inputEl).toBeDefined(); + fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } }); + expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com']); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx index 0e5f9a5e028b5..6c87a983f58a4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx @@ -132,7 +132,7 @@ const SortableTextField: FunctionComponent = React.memo( export const HostsInput: FunctionComponent = ({ id, - value, + value: valueFromProps, onChange, helpText, label, @@ -140,6 +140,10 @@ export const HostsInput: FunctionComponent = ({ errors, }) => { const [autoFocus, setAutoFocus] = useState(false); + const value = useMemo(() => { + return valueFromProps.length ? valueFromProps : ['']; + }, [valueFromProps]); + const rows = useMemo( () => value.map((host, idx) => ({ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx index 47c327b17c241..cbc2f7b5f7888 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx @@ -60,8 +60,10 @@ export const markdownRenderers = { ), code: ({ language, value }: { language: string; value: string }) => { + // Old packages are using `$json`, which is not valid any more with the move to prism.js + const parsedLang = language === '$json' ? 'json' : language; return ( - + {value} ); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index dcc685bb270b4..ae7bff618dba2 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -611,6 +611,96 @@ describe('EPM template', () => { expect(JSON.stringify(mappings)).toEqual(JSON.stringify(constantKeywordMapping)); }); + it('processes meta fields', () => { + const metaFieldLiteralYaml = ` +- name: fieldWithMetas + type: integer + unit: byte + metric_type: gauge + `; + const metaFieldMapping = { + properties: { + fieldWithMetas: { + type: 'long', + meta: { + metric_type: 'gauge', + unit: 'byte', + }, + }, + }, + }; + const fields: Field[] = safeLoad(metaFieldLiteralYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping)); + }); + + it('processes meta fields with only one meta value', () => { + const metaFieldLiteralYaml = ` +- name: fieldWithMetas + type: integer + metric_type: gauge + `; + const metaFieldMapping = { + properties: { + fieldWithMetas: { + type: 'long', + meta: { + metric_type: 'gauge', + }, + }, + }, + }; + const fields: Field[] = safeLoad(metaFieldLiteralYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping)); + }); + + it('processes grouped meta fields', () => { + const metaFieldLiteralYaml = ` +- name: groupWithMetas + type: group + unit: byte + metric_type: gauge + fields: + - name: fieldA + type: integer + unit: byte + metric_type: gauge + - name: fieldB + type: integer + unit: byte + metric_type: gauge + `; + const metaFieldMapping = { + properties: { + groupWithMetas: { + properties: { + fieldA: { + type: 'long', + meta: { + metric_type: 'gauge', + unit: 'byte', + }, + }, + fieldB: { + type: 'long', + meta: { + metric_type: 'gauge', + unit: 'byte', + }, + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(metaFieldLiteralYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping)); + }); + it('tests priority and index pattern for data stream without dataset_is_prefix', () => { const dataStreamDatasetIsPrefixUnset = { type: 'metrics', diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index f6ca1dfc99f4e..64261226a7944 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -42,6 +42,8 @@ const DATASET_IS_PREFIX_TEMPLATE_PRIORITY = 150; const QUERY_DEFAULT_FIELD_TYPES = ['keyword', 'text']; const QUERY_DEFAULT_FIELD_LIMIT = 1024; +const META_PROP_KEYS = ['metric_type', 'unit']; + /** * getTemplate retrieves the default template but overwrites the index pattern with the given value. * @@ -162,6 +164,22 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings { default: fieldProps.type = type; } + + const fieldHasMetaProps = META_PROP_KEYS.some((key) => key in field); + if (fieldHasMetaProps) { + switch (type) { + case 'group': + case 'group-nested': + break; + default: { + const meta = {}; + if ('metric_type' in field) Reflect.set(meta, 'metric_type', field.metric_type); + if ('unit' in field) Reflect.set(meta, 'unit', field.unit); + fieldProps.meta = meta; + } + } + } + props[field.name] = fieldProps; }); } diff --git a/x-pack/plugins/fleet/server/services/epm/fields/field.ts b/x-pack/plugins/fleet/server/services/epm/fields/field.ts index addcaf20cd146..b8839b88bb78c 100644 --- a/x-pack/plugins/fleet/server/services/epm/fields/field.ts +++ b/x-pack/plugins/fleet/server/services/epm/fields/field.ts @@ -34,6 +34,10 @@ export interface Field { include_in_parent?: boolean; include_in_root?: boolean; + // Meta fields + metric_type?: string; + unit?: string; + // Kibana specific analyzed?: boolean; count?: number; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index a8d610f2740de..b14d391c2c969 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -44,15 +44,6 @@ export function DimensionContainer({ setFocusTrapIsEnabled(false); }, [handleClose]); - useEffect(() => { - if (isOpen) { - // without setTimeout here the flyout pushes content when animating - setTimeout(() => { - setFocusTrapIsEnabled(true); - }, 255); - } - }, [isOpen]); - const closeOnEscape = useCallback( (event: KeyboardEvent) => { if (event.key === keys.ESCAPE) { @@ -83,6 +74,13 @@ export function DimensionContainer({ role="dialog" aria-labelledby="lnsDimensionContainerTitle" className="lnsDimensionContainer euiFlyout" + onAnimationEnd={() => { + if (isOpen) { + // EuiFocusTrap interferes with animating elements with absolute position: + // running this onAnimationEnd, otherwise the flyout pushes content when animating + setFocusTrapIsEnabled(true); + } + }} > ; + 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 85f7601d8fb29..ec12e9e400203 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 ea5eb14d9c20e..c8676faad0eea 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 7732b53db62fb..2ae7b9403a46d 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 25cf20e304daf..03db6141b917f 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 bf5b64bf3d615..61e5da5931e88 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 0000000000000..0ac02c15b34a5 --- /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 81dff1da57809..64b0bdd7ca2a6 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 8b60cf134fe6f..7cb49de15c066 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 fc9504f003198..823ec3eb58a92 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 2adb9a1376f60..c4f01e27be886 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 06555a9b41c2f..7c48b5742b8db 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 0e74ef6b85c80..a3d0241d4887e 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 59dbf74c11480..1f4f097c6a7fb 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 e77357a6f441a..1911af0a6f679 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 aa6b8675333c5..ae606a5851665 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 fd474ea04a165..7bf463a2095ad 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 cbc83db7e5f37..164415c1a1f6f 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 4632d262c441d..bde80accfbc67 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 4c09ae4ed8c47..aa8f951d46b4f 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 aab957c8ecebe..b272d1703377c 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 4dd56d2de1144..38bc84ae9af35 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 @@ -965,6 +965,41 @@ describe('state_helpers', () => { ); }); + it('should not carry over label when operation and field change at the same time', () => { + expect( + replaceColumn({ + layer: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'My custom label', + customLabel: true, + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }, + }, + indexPattern, + columnId: 'col1', + op: 'terms', + field: indexPattern.fields[4], + visualizationGroups: [], + }).columns.col1 + ).toEqual( + expect.objectContaining({ + label: 'Top values of source', + }) + ); + }); + it('should carry over label on operation switch when customLabel flag on previousColumn is set', () => { expect( replaceColumn({ @@ -2589,7 +2624,10 @@ describe('state_helpers', () => { col1: { operationType: 'average' }, }, }, - indexPattern + indexPattern, + {}, + '1', + {} ); expect(mock).toHaveBeenCalled(); expect(errors).toHaveLength(1); @@ -2608,7 +2646,10 @@ describe('state_helpers', () => { { operationType: 'testReference', references: [] }, }, }, - indexPattern + indexPattern, + {}, + '1', + {} ); expect(mock).toHaveBeenCalled(); expect(errors).toHaveLength(1); @@ -2637,7 +2678,10 @@ describe('state_helpers', () => { col1: { operationType: 'testIncompleteReference' }, }, }, - indexPattern + indexPattern, + {}, + '1', + {} ); expect(savedRef).toHaveBeenCalled(); expect(incompleteRef).not.toHaveBeenCalled(); @@ -2659,7 +2703,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 92452a11e94c1..56fbb8edef5b4 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, }, }, }; @@ -827,12 +838,14 @@ function applyReferenceTransition({ ); } -function copyCustomLabel( - newColumn: IndexPatternColumn, - previousOptions: { customLabel?: boolean; label: string } -) { +function copyCustomLabel(newColumn: IndexPatternColumn, previousOptions: IndexPatternColumn) { const adjustedColumn = { ...newColumn }; - if (previousOptions.customLabel) { + const operationChanged = newColumn.operationType !== previousOptions.operationType; + const fieldChanged = + ('sourceField' in newColumn && newColumn.sourceField) !== + ('sourceField' in previousOptions && previousOptions.sourceField); + // only copy custom label if either used operation or used field stayed the same + if (previousOptions.customLabel && (!operationChanged || !fieldChanged)) { adjustedColumn.customLabel = true; adjustedColumn.label = previousOptions.label; } @@ -1135,20 +1148,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 d53940aa585fd..152fcaa457c3b 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 07806a32665dd..a0b61060b9f3a 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 430e139a85cca..49bec5f58c29c 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 23c7adb86d34f..a9e24c70ab8ac 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 5a520438ec9e7..3394616618672 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; } /** @@ -677,7 +691,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 6cddd2c60f416..6b7e197a4d561 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/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index 11642f4083d39..3eda13f5bcb38 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -94,12 +94,12 @@ describe('alert_form', () => { id: 'alert-action-type', iconClass: '', selectMessage: '', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index 0d79f76be341c..fc60800bc4403 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ import rison, { RisonValue } from 'rison-node'; -import type { AllSeries, AllShortSeries } from '../hooks/use_url_storage'; +import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage'; import type { SeriesUrl } from '../types'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index cf51c4614e543..fc0062694e0a3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/dom'; -import { render, mockUrlStorage, mockCore, mockAppIndexPattern } from './rtl_helpers'; +import { render, mockCore, mockAppIndexPattern } from './rtl_helpers'; import { ExploratoryView } from './exploratory_view'; import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils'; import * as obsvInd from './utils/observability_index_patterns'; @@ -41,26 +41,26 @@ describe('ExploratoryView', () => { it('renders exploratory view', async () => { render(); - await waitFor(() => { - screen.getByText(/open in lens/i); - screen.getByRole('heading', { name: /analyze data/i }); - }); + expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: /Performance Distribution/i }) + ).toBeInTheDocument(); }); it('renders lens component when there is series', async () => { - mockUrlStorage({ + const initSeries = { data: { 'ux-series': { - dataType: 'ux', - reportType: 'pld', - breakdown: 'user_agent.name', + dataType: 'ux' as const, + reportType: 'pld' as const, + breakdown: 'user_agent .name', reportDefinitions: { 'service.name': ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); expect(await screen.findByText('Performance Distribution')).toBeInTheDocument(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index 19136cda6387c..7958dca6e396e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { ExploratoryViewHeader } from './header/header'; -import { useUrlStorage } from './hooks/use_url_storage'; +import { useSeriesStorage } from './hooks/use_series_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { EmptyView } from './components/empty_view'; import { TypedLensByValueInput } from '../../../../../lens/public'; @@ -19,7 +19,11 @@ import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; import { ReportToDataTypeMap } from './configurations/constants'; import { SeriesBuilder } from './series_builder/series_builder'; -export function ExploratoryView() { +export function ExploratoryView({ + saveAttributes, +}: { + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { const { services: { lens, notifications }, } = useKibana(); @@ -28,6 +32,7 @@ export function ExploratoryView() { const wrapperRef = useRef(null); const [height, setHeight] = useState('100vh'); + const [seriesId, setSeriesId] = useState(''); const [lensAttributes, setLensAttributes] = useState( null @@ -37,7 +42,11 @@ export function ExploratoryView() { const LensComponent = lens?.EmbeddableComponent; - const { firstSeriesId: seriesId, firstSeries: series, setSeries } = useUrlStorage(); + const { firstSeriesId, firstSeries: series, setSeries, allSeries } = useSeriesStorage(); + + useEffect(() => { + setSeriesId(firstSeriesId); + }, [allSeries, firstSeriesId]); const lensAttributesT = useLensAttributes({ seriesId, @@ -59,6 +68,10 @@ export function ExploratoryView() { useEffect(() => { setLensAttributes(lensAttributesT); + if (saveAttributes) { + saveAttributes(lensAttributesT); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx index dec69dc0a7b33..ca9f2c9e73eb8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mockUrlStorage, render } from '../rtl_helpers'; +import { render } from '../rtl_helpers'; import { ExploratoryViewHeader } from './header'; import { fireEvent } from '@testing-library/dom'; @@ -22,22 +22,23 @@ describe('ExploratoryViewHeader', function () { }); it('should be able to click open in lens', function () { - mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; const { getByText, core } = render( + />, + { initSeries } ); fireEvent.click(getByText('Open in Lens')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 8f2f30185d37f..3265287a7f915 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -12,7 +12,7 @@ import { TypedLensByValueInput } from '../../../../../../lens/public'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; import { DataViewLabels } from '../configurations/constants'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; interface Props { seriesId: string; @@ -24,7 +24,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { services: { lens }, } = useKibana(); - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); return ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index ea6f435460401..4e9c360745b6b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { LensAttributes } from '../configurations/lens_attributes'; -import { useUrlStorage } from './use_url_storage'; +import { useSeriesStorage } from './use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DataSeries, SeriesUrl, UrlFilter } from '../types'; @@ -40,8 +40,8 @@ export const getFiltersFromDefs = ( export const useLensAttributes = ({ seriesId, }: Props): TypedLensByValueInput['attributes'] | null => { - const { series } = useUrlStorage(seriesId); - + const { getSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const { breakdown, seriesType, operationType, reportType, reportDefinitions = {} } = series ?? {}; const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts index 2605818ed7846..2d2618bc46152 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useUrlStorage } from './use_url_storage'; +import { useSeriesStorage } from './use_series_storage'; import { UrlFilter } from '../types'; export interface UpdateFilter { @@ -15,7 +15,9 @@ export interface UpdateFilter { } export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const filters = series.filters ?? []; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx similarity index 51% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx index 498886cc94410..fac75f910a93f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -5,8 +5,11 @@ * 2.0. */ -import React, { createContext, useContext, Context } from 'react'; -import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public'; +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from '../../../../../../../../src/plugins/kibana_utils/public'; import type { AppDataType, ReportViewTypeId, @@ -18,17 +21,81 @@ import { convertToShortUrl } from '../configurations/utils'; import { OperationType, SeriesType } from '../../../../../../lens/public'; import { URL_KEYS } from '../configurations/constants/url_constants'; -export const UrlStorageContext = createContext(null); +export interface SeriesContextValue { + firstSeries: SeriesUrl; + firstSeriesId: string; + allSeriesIds: string[]; + allSeries: AllSeries; + setSeries: (seriesIdN: string, newValue: SeriesUrl) => void; + getSeries: (seriesId: string) => SeriesUrl; + removeSeries: (seriesId: string) => void; +} +export const UrlStorageContext = createContext({} as SeriesContextValue); interface ProviderProps { - storage: IKbnUrlStateStorage; + storage: IKbnUrlStateStorage | ISessionStorageStateStorage; } export function UrlStorageContextProvider({ children, storage, }: ProviderProps & { children: JSX.Element }) { - return {children}; + const allSeriesKey = 'sr'; + + const [allShortSeries, setAllShortSeries] = useState( + () => storage.get(allSeriesKey) ?? {} + ); + const [allSeries, setAllSeries] = useState({}); + const [firstSeriesId, setFirstSeriesId] = useState(''); + + useEffect(() => { + const allSeriesIds = Object.keys(allShortSeries); + const allSeriesN: AllSeries = {}; + allSeriesIds.forEach((seriesKey) => { + allSeriesN[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); + }); + + setAllSeries(allSeriesN); + setFirstSeriesId(allSeriesIds?.[0]); + (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); + }, [allShortSeries, storage]); + + const setSeries = (seriesIdN: string, newValue: SeriesUrl) => { + setAllShortSeries((prevState) => { + prevState[seriesIdN] = convertToShortUrl(newValue); + return { ...prevState }; + }); + }; + + const removeSeries = (seriesIdN: string) => { + delete allShortSeries[seriesIdN]; + delete allSeries[seriesIdN]; + }; + + const allSeriesIds = Object.keys(allShortSeries); + + const getSeries = useCallback( + (seriesId?: string) => { + return seriesId ? allSeries?.[seriesId] ?? {} : ({} as SeriesUrl); + }, + [allSeries] + ); + + const value = { + storage, + getSeries, + setSeries, + removeSeries, + firstSeriesId, + allSeries, + allSeriesIds, + firstSeries: allSeries?.[firstSeriesId], + }; + return {children}; +} + +export function useSeriesStorage() { + return useContext(UrlStorageContext); } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { @@ -64,47 +131,3 @@ export type AllShortSeries = Record; export type AllSeries = Record; export const NEW_SERIES_KEY = 'new-series-key'; - -export function useUrlStorage(seriesId?: string) { - const allSeriesKey = 'sr'; - const storage = useContext((UrlStorageContext as unknown) as Context); - let series: SeriesUrl = {} as SeriesUrl; - const allShortSeries = storage.get(allSeriesKey) ?? {}; - - const allSeriesIds = Object.keys(allShortSeries); - - const allSeries: AllSeries = {}; - - allSeriesIds.forEach((seriesKey) => { - allSeries[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); - }); - - if (seriesId) { - series = allSeries?.[seriesId] ?? ({} as SeriesUrl); - } - - const setSeries = async (seriesIdN: string, newValue: SeriesUrl) => { - allShortSeries[seriesIdN] = convertToShortUrl(newValue); - allSeries[seriesIdN] = newValue; - return storage.set(allSeriesKey, allShortSeries); - }; - - const removeSeries = (seriesIdN: string) => { - delete allShortSeries[seriesIdN]; - delete allSeries[seriesIdN]; - storage.set(allSeriesKey, allShortSeries); - }; - - const firstSeriesId = allSeriesIds?.[0]; - - return { - storage, - setSeries, - removeSeries, - series, - firstSeriesId, - allSeries, - allSeriesIds, - firstSeries: allSeries?.[firstSeriesId], - }; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index 80b6b29f88303..3de29b02853e8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -17,11 +17,19 @@ import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; import { createKbnUrlStateStorage, withNotifyOnErrors, + createSessionStorageStateStorage, } from '../../../../../../../src/plugins/kibana_utils/public/'; -import { UrlStorageContextProvider } from './hooks/use_url_storage'; +import { UrlStorageContextProvider } from './hooks/use_series_storage'; import { useTrackPageview } from '../../..'; +import { TypedLensByValueInput } from '../../../../../lens/public'; -export function ExploratoryViewPage() { +export function ExploratoryViewPage({ + saveAttributes, + useSessionStorage = false, +}: { + useSessionStorage?: boolean; + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' }); useTrackPageview({ app: 'observability-overview', path: 'exploratory-view', delay: 15000 }); @@ -39,17 +47,19 @@ export function ExploratoryViewPage() { const history = useHistory(); - const kbnUrlStateStorage = createKbnUrlStateStorage({ - history, - useHash: uiSettings!.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(notifications!.toasts), - }); + const kbnUrlStateStorage = useSessionStorage + ? createSessionStorageStateStorage() + : createKbnUrlStateStorage({ + history, + useHash: uiSettings!.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(notifications!.toasts), + }); return ( - + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index beb1daafbd55f..9118e49a42dfb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -16,29 +16,23 @@ import { CoreStart } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; import { coreMock } from 'src/core/public/mocks'; import { - KibanaServices, KibanaContextProvider, + KibanaServices, } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { lensPluginMock } from '../../../../../lens/public/mocks'; +import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; -import { AllSeries, UrlStorageContextProvider } from './hooks/use_url_storage'; -import { - withNotifyOnErrors, - createKbnUrlStateStorage, -} from '../../../../../../../src/plugins/kibana_utils/public'; +import { AllSeries, UrlStorageContext } from './hooks/use_series_storage'; + import * as fetcherHook from '../../../hooks/use_fetcher'; -import * as useUrlHook from './hooks/use_url_storage'; import * as useSeriesFilterHook from './hooks/use_series_filters'; import * as useHasDataHook from '../../../hooks/use_has_data'; import * as useValuesListHook from '../../../hooks/use_values_list'; -import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/index_patterns/index_pattern.stub'; import indexPatternData from './configurations/test_data/test_index_pattern.json'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { setIndexPatterns } from '../../../../../../../src/plugins/data/public/services'; import { IndexPatternsContract } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; @@ -73,6 +67,11 @@ interface RenderRouterOptions extends KibanaProviderOptions; url?: Url; + initSeries?: { + data?: AllSeries; + filters?: UrlFilter[]; + breakdown?: string; + }; } function getSetting(key: string): T { @@ -127,17 +126,8 @@ export const mockCore: () => Partial>({ children, core, - history, kibanaProps, }: MockKibanaProviderProps) { - const { notifications } = core!; - - const kbnUrlStateStorage = createKbnUrlStateStorage({ - history, - useHash: false, - ...withNotifyOnErrors(notifications!.toasts), - }); - const indexPattern = mockIndexPattern; setIndexPatterns(({ @@ -149,11 +139,7 @@ export function MockKibanaProvider>({ - - - {children} - - + {children} @@ -184,6 +170,7 @@ export function render( kibanaProps, renderOptions, url, + initSeries = {}, }: RenderRouterOptions = {} ) { if (url) { @@ -195,15 +182,20 @@ export function render( ...customCore, }; + const seriesContextValue = mockSeriesStorageContext(initSeries); + return { ...reactTestLibRender( - {ui} + + {ui} + , renderOptions ), history, core, + ...seriesContextValue, }; } @@ -256,7 +248,7 @@ export const mockUseValuesList = (values?: string[]) => { return { spy, onRefreshTimeRange }; }; -export const mockUrlStorage = ({ +function mockSeriesStorageContext({ data, filters, breakdown, @@ -264,7 +256,7 @@ export const mockUrlStorage = ({ data?: AllSeries; filters?: UrlFilter[]; breakdown?: string; -}) => { +}) { const mockDataSeries = data || { 'performance-distribution': { reportType: 'pld', @@ -282,18 +274,18 @@ export const mockUrlStorage = ({ const removeSeries = jest.fn(); const setSeries = jest.fn(); - const spy = jest.spyOn(useUrlHook, 'useUrlStorage').mockReturnValue({ + const getSeries = jest.fn().mockReturnValue(series); + + return { firstSeriesId, allSeriesIds, removeSeries, setSeries, - series, + getSeries, firstSeries: mockDataSeries[firstSeriesId], allSeries: mockDataSeries, - } as any); - - return { spy, removeSeries, setSeries }; -}; + }; +} export function mockUseSeriesFilter() { const removeFilter = jest.fn(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx index bac935dbecbe7..c054853d9c877 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx @@ -7,13 +7,11 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; +import { render } from '../../rtl_helpers'; import { SeriesChartTypesSelect, XYChartTypesSelect } from './chart_types'; describe.skip('SeriesChartTypesSelect', function () { it('should render properly', async function () { - mockUrlStorage({}); - render(); await waitFor(() => { @@ -22,9 +20,9 @@ describe.skip('SeriesChartTypesSelect', function () { }); it('should call set series on change', async function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); await waitFor(() => { screen.getByText(/chart type/i); @@ -44,8 +42,6 @@ describe.skip('SeriesChartTypesSelect', function () { describe('XYChartTypesSelect', function () { it('should render properly', async function () { - mockUrlStorage({}); - render(); await waitFor(() => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx index a296d2520db34..9ae8b68bf3e8c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../../plugin'; import { useFetcher } from '../../../../..'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { SeriesType } from '../../../../../../../lens/public'; const CHART_TYPE_LABEL = i18n.translate('xpack.observability.expView.chartTypes.label', { @@ -27,7 +27,9 @@ export function SeriesChartTypesSelect({ seriesTypes?: SeriesType[]; defaultChartType: SeriesType; }) { - const { series, setSeries, allSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, allSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const seriesType = series?.seriesType ?? defaultChartType; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx index 9348fcbe15f6c..51529a3b1ac17 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, render } from '../../rtl_helpers'; import { dataTypes, DataTypesCol } from './data_types_col'; describe('DataTypesCol', function () { @@ -24,9 +24,7 @@ describe('DataTypesCol', function () { }); it('should set series on change', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render(); fireEvent.click(screen.getByText(/user experience \(rum\)/i)); @@ -35,18 +33,18 @@ describe('DataTypesCol', function () { }); it('should set series on change on already selected', function () { - mockUrlStorage({ + const initSeries = { data: { [seriesId]: { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); const button = screen.getByRole('button', { name: /Synthetic Monitoring/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx index b64fad51e9778..08e7f4ddcd3d0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx @@ -10,7 +10,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { AppDataType } from '../../types'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { ReportToDataTypeMap } from '../../configurations/constants'; export const dataTypes: Array<{ id: AppDataType; label: string }> = [ @@ -22,8 +22,9 @@ export const dataTypes: Array<{ id: AppDataType; label: string }> = [ ]; export function DataTypesCol({ seriesId }: { seriesId: string }) { - const { series, setSeries, removeSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, removeSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const { loading } = useAppIndexPatternContext(); const onDataTypeChange = (dataType?: AppDataType) => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx index 9550b8e98103b..c262a94f968be 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; +import { render } from '../../rtl_helpers'; import { OperationTypeSelect } from './operation_type_select'; describe('OperationTypeSelect', function () { @@ -18,35 +18,35 @@ describe('OperationTypeSelect', function () { }); it('should display selected value', function () { - mockUrlStorage({ + const initSeries = { data: { 'performance-distribution': { - dataType: 'ux', - reportType: 'kpi', - operationType: 'median', + dataType: 'ux' as const, + reportType: 'kpi' as const, + operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); screen.getByText('Median'); }); it('should call set series on change', function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { 'series-id': { - dataType: 'ux', - reportType: 'kpi', - operationType: 'median', + dataType: 'ux' as const, + reportType: 'kpi' as const, + operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + const { setSeries } = render(, { initSeries }); fireEvent.click(screen.getByTestId('operationTypeSelect')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx index 75203d7bae3a0..fa273f6180935 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSuperSelect } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { OperationType } from '../../../../../../../lens/public'; export function OperationTypeSelect({ @@ -19,7 +19,9 @@ export function OperationTypeSelect({ seriesId: string; defaultOperationType?: OperationType; }) { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const operationType = series?.operationType; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx index 3363d17d81eab..f576862f18e76 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx @@ -7,9 +7,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { render } from '../../../../../utils/test_helper'; import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; +import { mockIndexPattern, render } from '../../rtl_helpers'; import { ReportBreakdowns } from './report_breakdowns'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -22,8 +21,6 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should render properly', function () { - mockUrlStorage({}); - render(); screen.getByText('Select an option: , is selected'); @@ -31,9 +28,9 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should set new series breakdown on change', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, @@ -53,9 +50,9 @@ describe('Series Builder ReportBreakdowns', function () { }); }); it('should set undefined on new series on no select breakdown', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx index 27adcf4682c02..fdf6633c0ddb5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx @@ -11,7 +11,6 @@ import { getDefaultConfigs } from '../../configurations/default_configs'; import { mockAppIndexPattern, mockIndexPattern, - mockUrlStorage, mockUseValuesList, render, } from '../../rtl_helpers'; @@ -28,21 +27,23 @@ describe('Series Builder ReportDefinitionCol', function () { indexPattern: mockIndexPattern, }); - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { [seriesId]: { - dataType: 'ux', - reportType: 'pld', + dataType: 'ux' as const, + reportType: 'pld' as const, time: { from: 'now-30d', to: 'now' }, reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, }, }, - }); + }; mockUseValuesList(['elastic-co']); it('should render properly', async function () { - render(); + render(, { + initSeries, + }); screen.getByText('Web Application'); screen.getByText('Environment'); @@ -51,7 +52,9 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should render selected report definitions', async function () { - render(); + render(, { + initSeries, + }); expect(await screen.findByText('elastic-co')).toBeInTheDocument(); @@ -59,7 +62,10 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should be able to remove selected definition', async function () { - render(); + const { setSeries } = render( + , + { initSeries } + ); expect( await screen.findByLabelText('Remove elastic-co from selection in this group') diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx index ff8b0f7aa578b..338f5d52c26fa 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import styled from 'styled-components'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { CustomReportField } from '../custom_report_field'; import { DataSeries, URLReportDefinition } from '../../types'; import { SeriesChartTypesSelect } from './chart_types'; @@ -38,9 +38,11 @@ export function ReportDefinitionCol({ }) { const { indexPattern } = useAppIndexPatternContext(); - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); - const { reportDefinitions: selectedReportDefinitions = {} } = series; + const series = getSeries(seriesId); + + const { reportDefinitions: selectedReportDefinitions = {} } = series ?? {}; const { reportDefinitions, defaultSeriesType, hasOperationType, yAxisColumns } = dataViewSeries; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx index 9f92bec4d1f9c..1a6d2af8f4d40 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash'; import FieldValueSuggestions from '../../../field_value_suggestions'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; import { ESFilter } from '../../../../../../../../../typings/elasticsearch'; import { PersistableFilter } from '../../../../../../../lens/common'; @@ -25,7 +25,9 @@ interface Props { } export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx index 1467cb54d648a..dc2dc629cc121 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx @@ -7,10 +7,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { render } from '../../../../../utils/test_helper'; import { ReportFilters } from './report_filters'; import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; +import { mockIndexPattern, render } from '../../rtl_helpers'; describe('Series Builder ReportFilters', function () { const seriesId = 'test-series-id'; @@ -20,7 +19,7 @@ describe('Series Builder ReportFilters', function () { reportType: 'pld', indexPattern: mockIndexPattern, }); - mockUrlStorage({}); + it('should render properly', function () { render(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index 20c4ea98d482d..c721a2fa2fe77 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -7,11 +7,11 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, render } from '../../rtl_helpers'; import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col'; import { ReportTypes } from '../series_builder'; import { DEFAULT_TIME } from '../../configurations/constants'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; +import { NEW_SERIES_KEY } from '../../hooks/use_series_storage'; describe('ReportTypesCol', function () { const seriesId = 'test-series-id'; @@ -30,8 +30,9 @@ describe('ReportTypesCol', function () { }); it('should set series on change', function () { - const { setSeries } = mockUrlStorage({}); - render(); + const { setSeries } = render( + + ); fireEvent.click(screen.getByText(/monitor duration/i)); @@ -46,18 +47,21 @@ describe('ReportTypesCol', function () { }); it('should set selected as filled', function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { [NEW_SERIES_KEY]: { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + const { setSeries } = render( + , + { initSeries } + ); const button = screen.getByRole('button', { name: /pings histogram/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index bd82d1d1bd500..9fff8dae14a47 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { ReportViewTypeId, SeriesUrl } from '../../types'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { DEFAULT_TIME } from '../../configurations/constants'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; @@ -21,10 +21,9 @@ interface Props { } export function ReportTypesCol({ seriesId, reportTypes }: Props) { - const { - series: { reportType: selectedReportType, ...restSeries }, - setSeries, - } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + + const { reportType: selectedReportType, ...restSeries } = getSeries(seriesId); const { loading, hasData, selectedApp } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx index b41f3a603e5da..201df9628e135 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { ReportDefinition } from '../types'; interface Props { @@ -18,7 +18,9 @@ interface Props { } export function CustomReportField({ field, seriesId, options: opts }: Props) { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { reportDefinitions: rtd = {} } = series; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index 1944bb281598b..32f1fb7f7c43b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -15,7 +15,7 @@ import { ReportTypesCol } from './columns/report_types_col'; import { ReportDefinitionCol } from './columns/report_definition_col'; import { ReportFilters } from './columns/report_filters'; import { ReportBreakdowns } from './columns/report_breakdowns'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; import { getDefaultConfigs } from '../configurations/default_configs'; @@ -53,7 +53,9 @@ export function SeriesBuilder({ seriesId: string; seriesBuilderRef: RefObject; }) { - const { series, setSeries, removeSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, removeSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { dataType, @@ -156,9 +158,8 @@ export function SeriesBuilder({ reportDefinitions, }; - setSeries(newSeriesId, newSeriesN).then(() => { - removeSeries(NEW_SERIES_KEY); - }); + setSeries(newSeriesId, newSeriesN); + removeSeries(NEW_SERIES_KEY); } }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx index 960c2978287bc..d6a70532f4257 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx @@ -8,7 +8,7 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import React, { useEffect } from 'react'; import { useHasData } from '../../../../hooks/use_has_data'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges'; import { DEFAULT_TIME } from '../configurations/constants'; @@ -30,7 +30,9 @@ export function SeriesDatePicker({ seriesId }: Props) { const commonlyUsedRanges = useQuickTimeRanges(); - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); function onTimeChange({ start, end }: { start: string; end: string }) { onRefreshTimeRange(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx index e99b701f091fe..0edc4330ef97a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx @@ -6,62 +6,67 @@ */ import React from 'react'; -import { mockUrlStorage, mockUseHasData, render } from '../rtl_helpers'; +import { mockUseHasData, render } from '../rtl_helpers'; import { fireEvent, waitFor } from '@testing-library/react'; import { SeriesDatePicker } from './index'; import { DEFAULT_TIME } from '../configurations/constants'; describe('SeriesDatePicker', function () { it('should render properly', function () { - mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, }, - }); - const { getByText } = render(); + }; + const { getByText } = render(, { initSeries }); getByText('Last 30 minutes'); }); it('should set defaults', async function () { - const { setSeries: setSeries1 } = mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - reportType: 'upp', - dataType: 'synthetics', + reportType: 'upp' as const, + dataType: 'synthetics' as const, breakdown: 'monitor.status', }, }, - } as any); - render(); + }; + const { setSeries: setSeries1 } = render( + , + { initSeries: initSeries as any } + ); expect(setSeries1).toHaveBeenCalledTimes(1); expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', { breakdown: 'monitor.status', - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, time: DEFAULT_TIME, }); }); it('should set series data', async function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, }, - }); + }; const { onRefreshTimeRange } = mockUseHasData(); - const { getByTestId } = render(); + const { getByTestId, setSeries } = render(, { + initSeries, + }); await waitFor(function () { fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx index 9d26ec79c31ad..0ce9db73f92b1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { Breakdowns } from './breakdowns'; -import { mockIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; +import { mockIndexPattern, render } from '../../rtl_helpers'; +import { NEW_SERIES_KEY } from '../../hooks/use_series_storage'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -21,8 +21,6 @@ describe('Breakdowns', function () { }); it('should render properly', async function () { - mockUrlStorage({}); - render( + />, + { initSeries } ); screen.getAllByText('Operating system'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx index 5cf6ac47aa8c7..cf24cb31951b1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { USE_BREAK_DOWN_COLUMN } from '../../configurations/constants'; -import { useUrlStorage } from '../../hooks/use_url_storage'; import { DataSeries } from '../../types'; interface Props { @@ -19,7 +19,9 @@ interface Props { } export function Breakdowns({ reportViewConfig, seriesId, breakdowns = [] }: Props) { - const { setSeries, series } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const selectedBreakdown = series.breakdown; const NO_BREAKDOWN = 'no_breakdown'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 8d3060792857e..1a8c5b335bc4f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockAppIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { it('should render properly', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; mockAppIndexPattern(); render( @@ -22,13 +22,14 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={jest.fn()} - /> + />, + { initSeries } ); screen.getByText('Browser Family'); }); it('should call go back on click', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; const goBack = jest.fn(); render( @@ -37,7 +38,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={goBack} - /> + />, + { initSeries } ); fireEvent.click(screen.getByText('Browser Family')); @@ -47,7 +49,7 @@ describe('FilterExpanded', function () { }); it('should call useValuesList on load', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; const { spy } = mockUseValuesList(['Chrome', 'Firefox']); @@ -59,7 +61,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={goBack} - /> + />, + { initSeries } ); expect(spy).toHaveBeenCalledTimes(1); @@ -71,7 +74,7 @@ describe('FilterExpanded', function () { ); }); it('should filter display values', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; mockUseValuesList(['Chrome', 'Firefox']); @@ -81,7 +84,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={jest.fn()} - /> + />, + { initSeries } ); expect(screen.queryByText('Firefox')).toBeTruthy(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx index 7a646c9035968..cc1769cfa8c95 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { rgba } from 'polished'; import { i18n } from '@kbn/i18n'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { UrlFilter } from '../../types'; import { FilterValueButton } from './filter_value_btn'; import { useValuesList } from '../../../../../hooks/use_values_list'; @@ -33,7 +33,9 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is const [isOpen, setIsOpen] = useState({ value: '', negate: false }); - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { values, loading } = useValuesList({ query: value, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx index befbb3b74d6d7..79eb858b7624b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterValueButton } from './filter_value_btn'; -import { mockUrlStorage, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME, USER_AGENT_VERSION, @@ -75,7 +75,6 @@ describe('FilterValueButton', function () { }); }); it('should remove filter on click if already selected', async function () { - mockUrlStorage({}); const { removeFilter } = mockUseSeriesFilter(); render( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx index ccb9c90a884bb..ea84ec6b6c212 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { EuiFilterButton, hexToRgb } from '@elastic/eui'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useSeriesFilters } from '../../hooks/use_series_filters'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import FieldValueSuggestions from '../../../field_value_suggestions'; @@ -37,7 +37,9 @@ export function FilterValueButton({ nestedField, allSelectedValues, }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx index ba2cdc545fbef..dc84352ff3b3d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx @@ -8,14 +8,14 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { EuiButtonIcon } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; } export function RemoveSeries({ seriesId }: Props) { - const { removeSeries } = useUrlStorage(); + const { removeSeries } = useSeriesStorage(); const onClick = () => { removeSeries(seriesId); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx index cdc20e2d9ab6c..5374fc33093a1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx @@ -9,14 +9,15 @@ import React from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RemoveSeries } from './remove_series'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../../hooks/use_series_storage'; import { ReportToDataTypeMap } from '../../configurations/constants'; interface Props { seriesId: string; } export function SeriesActions({ seriesId }: Props) { - const { series, removeSeries, setSeries } = useUrlStorage(seriesId); + const { getSeries, removeSeries, setSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const onEdit = () => { removeSeries(seriesId); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 926852fda5cbc..9e5770c2de8f9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -19,7 +19,7 @@ import { FilterExpanded } from './filter_expanded'; import { DataSeries } from '../../types'; import { FieldLabels } from '../../configurations/constants/constants'; import { SelectedFilters } from '../selected_filters'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; @@ -53,7 +53,8 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P }; }); - const { setSeries, series: urlSeries } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + const urlSeries = getSeries(seriesId); const button = ( ); + render(, { initSeries }); await waitFor(() => { screen.getByText('Chrome'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx index aabb39f88507f..63abb581c9c72 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { FilterLabel } from '../components/filter_label'; import { DataSeries, UrlFilter } from '../types'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -20,7 +20,9 @@ interface Props { isNew?: boolean; } export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { reportDefinitions = {} } = series; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index d883b854c88cb..6e513fcd2fec9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -11,7 +11,7 @@ import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesFilter } from './columns/series_filter'; import { DataSeries } from '../types'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DatePickerCol } from './columns/date_picker_col'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -19,7 +19,7 @@ import { SeriesActions } from './columns/series_actions'; import { ChartEditOptions } from './chart_edit_options'; export function SeriesEditor() { - const { allSeries, firstSeriesId } = useUrlStorage(); + const { allSeries, firstSeriesId } = useSeriesStorage(); const columns = [ { diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 1c0b09a4648e5..c85778f2f38fa 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -32,3 +32,6 @@ export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; /** Host Isolation Routes */ export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`; export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`; + +/** Endpoint Actions Log Routes */ +export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index e8997158cdfad..32affddf46294 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -20,3 +20,11 @@ export const HostIsolationRequestSchema = { comment: schema.maybe(schema.string()), }), }; + +export const EndpointActionLogRequestSchema = { + // TODO improve when using pagination with query params + query: schema.object({}), + params: schema.object({ + agent_id: schema.string(), + }), +}; diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index d9de00be0ea9e..90eb9a38d7509 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -31,62 +31,17 @@ // -- This is will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) -import { findIndex } from 'lodash/fp'; - -const getFindRequestConfig = (searchStrategyName, factoryQueryType) => { - if (!factoryQueryType) { - return { - options: { strategy: searchStrategyName }, - }; - } - - return { - options: { strategy: searchStrategyName }, - request: { factoryQueryType }, - }; -}; - Cypress.Commands.add( 'stubSearchStrategyApi', function (stubObject, factoryQueryType, searchStrategyName = 'securitySolutionSearchStrategy') { cy.intercept('POST', '/internal/bsearch', (req) => { - const findRequestConfig = getFindRequestConfig(searchStrategyName, factoryQueryType); - const requestIndex = findIndex(findRequestConfig, req.body.batch); - - if (requestIndex > -1) { - return req.reply((res) => { - const responseObjectsArray = res.body.split('\n').map((responseString) => { - try { - return JSON.parse(responseString); - } catch { - return responseString; - } - }); - const responseIndex = findIndex({ id: requestIndex }, responseObjectsArray); - - const stubbedResponseObjectsArray = [...responseObjectsArray]; - stubbedResponseObjectsArray[responseIndex] = { - ...stubbedResponseObjectsArray[responseIndex], - result: { - ...stubbedResponseObjectsArray[responseIndex].result, - ...stubObject, - }, - }; - - const stubbedResponse = stubbedResponseObjectsArray - .map((object) => { - try { - return JSON.stringify(object); - } catch { - return object; - } - }) - .join('\n'); - - res.send(stubbedResponse); - }); + if (searchStrategyName === 'securitySolutionIndexFields') { + req.reply(stubObject.rawResponse); + } else if (factoryQueryType === 'overviewHost') { + req.reply(stubObject.overviewHost); + } else if (factoryQueryType === 'overviewNetwork') { + req.reply(stubObject.overviewNetwork); } - req.reply(); }); } diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx index 2fdb7e99d860e..740437646f61a 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx @@ -8,10 +8,14 @@ import React from 'react'; import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; -export const FormattedDateAndTime: React.FC<{ date: Date }> = ({ date }) => { +export const FormattedDateAndTime: React.FC<{ date: Date; showRelativeTime?: boolean }> = ({ + date, + showRelativeTime = false, +}) => { // If date is greater than or equal to 1h (ago), then show it as a date + // and if showRelativeTime is false // else, show it as relative to "now" - return Date.now() - date.getTime() >= 3.6e6 ? ( + return Date.now() - date.getTime() >= 3.6e6 && !showRelativeTime ? ( <> {' @'} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index a31371c31cbbb..8a85d35d77fac 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -89,6 +89,7 @@ const StepRuleActionsComponent: FC = ({ ...(defaultValues ?? stepActionsDefaultValue), kibanaSiemAppUrl: kibanaAbsoluteUrl, }; + const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); const { form } = useForm({ defaultValue: initialState, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx index 992f30e795bbf..3266d6f61eeed 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx @@ -15,13 +15,13 @@ describe('stepRuleActions schema', () => { const actionTypeRegistry = actionTypeRegistryMock.create(); describe('validateSingleAction', () => { - it('should validate single action', () => { + it('should validate single action', async () => { (isUuid as jest.Mock).mockReturnValue(true); (validateActionParams as jest.Mock).mockReturnValue([]); (validateMustache as jest.Mock).mockReturnValue([]); expect( - validateSingleAction( + await validateSingleAction( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -33,12 +33,12 @@ describe('stepRuleActions schema', () => { ).toHaveLength(0); }); - it('should validate single action with invalid mustache template', () => { + it('should validate single action with invalid mustache template', async () => { (isUuid as jest.Mock).mockReturnValue(true); (validateActionParams as jest.Mock).mockReturnValue([]); (validateMustache as jest.Mock).mockReturnValue(['Message is not valid mustache template']); - const errors = validateSingleAction( + const errors = await validateSingleAction( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -54,12 +54,12 @@ describe('stepRuleActions schema', () => { expect(errors[0]).toEqual('Message is not valid mustache template'); }); - it('should validate single action with incorrect id', () => { + it('should validate single action with incorrect id', async () => { (isUuid as jest.Mock).mockReturnValue(false); (validateMustache as jest.Mock).mockReturnValue([]); (validateActionParams as jest.Mock).mockReturnValue([]); - const errors = validateSingleAction( + const errors = await validateSingleAction( { id: '823d4', group: 'default', @@ -74,10 +74,10 @@ describe('stepRuleActions schema', () => { }); describe('validateRuleActionsField', () => { - it('should validate rule actions field', () => { + it('should validate rule actions field', async () => { const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [], form: {} as FormHook, @@ -88,11 +88,11 @@ describe('stepRuleActions schema', () => { expect(result).toEqual(undefined); }); - it('should validate incorrect rule actions field', () => { + it('should validate incorrect rule actions field', async () => { (getActionTypeName as jest.Mock).mockReturnValue('Slack'); const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [ { @@ -117,7 +117,7 @@ describe('stepRuleActions schema', () => { }); }); - it('should validate multiple incorrect rule actions field', () => { + it('should validate multiple incorrect rule actions field', async () => { (isUuid as jest.Mock).mockReturnValueOnce(false); (getActionTypeName as jest.Mock).mockReturnValueOnce('Slack'); (isUuid as jest.Mock).mockReturnValueOnce(true); @@ -126,7 +126,7 @@ describe('stepRuleActions schema', () => { (validateMustache as jest.Mock).mockReturnValue(['Component is not valid mustache template']); const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx index bc32bdc387cd2..a697d922eda97 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx @@ -13,42 +13,46 @@ import { AlertAction, ActionTypeRegistryContract, } from '../../../../../../triggers_actions_ui/public'; -import { FormSchema, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { + FormSchema, + ValidationFunc, + ERROR_CODE, + ValidationError, +} from '../../../../shared_imports'; import { ActionsStepRule } from '../../../pages/detection_engine/rules/types'; import * as I18n from './translations'; import { isUuid, getActionTypeName, validateMustache, validateActionParams } from './utils'; -export const validateSingleAction = ( +export const validateSingleAction = async ( actionItem: AlertAction, actionTypeRegistry: ActionTypeRegistryContract -): string[] => { +): Promise => { if (!isUuid(actionItem.id)) { return [I18n.NO_CONNECTOR_SELECTED]; } - const actionParamsErrors = validateActionParams(actionItem, actionTypeRegistry); + const actionParamsErrors = await validateActionParams(actionItem, actionTypeRegistry); const mustacheErrors = validateMustache(actionItem.params); return [...actionParamsErrors, ...mustacheErrors]; }; -export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => ( +export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => async ( ...data: Parameters -): ReturnType> | undefined => { +): Promise | void | undefined> => { const [{ value, path }] = data as [{ value: AlertAction[]; path: string }]; - const errors = value.reduce((acc, actionItem) => { - const errorsArray = validateSingleAction(actionItem, actionTypeRegistry); + const errors = []; + for (const actionItem of value) { + const errorsArray = await validateSingleAction(actionItem, actionTypeRegistry); if (errorsArray.length) { const actionTypeName = getActionTypeName(actionItem.actionTypeId); const errorsListItems = errorsArray.map((error) => `* ${error}\n`); - return [...acc, `\n**${actionTypeName}:**\n${errorsListItems.join('')}`]; + errors.push(`\n**${actionTypeName}:**\n${errorsListItems.join('')}`); } - - return acc; - }, [] as string[]); + } if (errors.length) { return { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts index 3d7299c1673b1..7c4ea71c983c8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts @@ -61,11 +61,11 @@ describe('stepRuleActions utils', () => { actionTypeRegistry.get.mockReturnValue(actionMock); }); - it('should validate action params', () => { + it('should validate action params', async () => { validateParamsMock.mockReturnValue({ errors: [] }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -79,13 +79,13 @@ describe('stepRuleActions utils', () => { ).toHaveLength(0); }); - it('should validate incorrect action params', () => { + it('should validate incorrect action params', async () => { validateParamsMock.mockReturnValue({ errors: ['Message is required'], }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -97,7 +97,7 @@ describe('stepRuleActions utils', () => { ).toHaveLength(1); }); - it('should validate incorrect action params and filter error objects', () => { + it('should validate incorrect action params and filter error objects', async () => { validateParamsMock.mockReturnValue({ errors: [ { @@ -107,7 +107,7 @@ describe('stepRuleActions utils', () => { }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -119,13 +119,13 @@ describe('stepRuleActions utils', () => { ).toHaveLength(0); }); - it('should validate incorrect action params and filter duplicated errors', () => { + it('should validate incorrect action params and filter duplicated errors', async () => { validateParamsMock.mockReturnValue({ errors: ['Message is required', 'Message is required', 'Message is required'], }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts index d241d4283fc77..22363df5164a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts @@ -41,11 +41,11 @@ export const validateMustache = (params: AlertAction['params']) => { return errors; }; -export const validateActionParams = ( +export const validateActionParams = async ( actionItem: AlertAction, actionTypeRegistry: ActionTypeRegistryContract -): string[] => { - const actionErrors = actionTypeRegistry +): Promise => { + const actionErrors = await actionTypeRegistry .get(actionItem.actionTypeId) ?.validateParams(actionItem.params); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 65185b4d05135..a7bd42c6af5ee 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -25,7 +25,7 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { resolvePathVariables } from '../../../../management/pages/trusted_apps/service/utils'; +import { resolvePathVariables } from '../../../../management/common/utils'; import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; /** diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 59455ccd6bb04..8918261b6a436 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseQueryFilterToKQL } from './utils'; +import { parseQueryFilterToKQL, resolvePathVariables } from './utils'; describe('utils', () => { const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`]; @@ -39,4 +39,39 @@ describe('utils', () => { ); }); }); + + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index c8cf761ccaf86..78a95eb4d6f81 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -19,3 +19,8 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly return kuery; }; + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index d80a7d03903ac..25f2631ef46ff 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -37,7 +37,6 @@ export interface ServerFailedToReturnEndpointDetails { type: 'serverFailedToReturnEndpointDetails'; payload: ServerApiError; } - export interface ServerReturnedEndpointPolicyResponse { type: 'serverReturnedEndpointPolicyResponse'; payload: GetHostPolicyResponse; @@ -137,19 +136,24 @@ export interface ServerFailedToReturnEndpointsTotal { payload: ServerApiError; } -type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { +export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { payload: HostIsolationRequestBody; }; -type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { +export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { payload: EndpointState['isolationRequestState']; }; +export type EndpointDetailsActivityLogChanged = Action<'endpointDetailsActivityLogChanged'> & { + payload: EndpointState['endpointDetails']['activityLog']; +}; + export type EndpointAction = | ServerReturnedEndpointList | ServerFailedToReturnEndpointList | ServerReturnedEndpointDetails | ServerFailedToReturnEndpointDetails + | EndpointDetailsActivityLogChanged | ServerReturnedEndpointPolicyResponse | ServerFailedToReturnEndpointPolicyResponse | ServerReturnedPoliciesForOnboarding diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts new file mode 100644 index 0000000000000..d5416d9f8ec96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts @@ -0,0 +1,53 @@ +/* + * 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 { Immutable } from '../../../../../common/endpoint/types'; +import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; +import { createUninitialisedResourceState } from '../../../state'; +import { EndpointState } from '../types'; + +export const initialEndpointPageState = (): Immutable => { + return { + hosts: [], + pageSize: 10, + pageIndex: 0, + total: 0, + loading: false, + error: undefined, + endpointDetails: { + activityLog: createUninitialisedResourceState(), + hostDetails: { + details: undefined, + detailsLoading: false, + detailsError: undefined, + }, + }, + policyResponse: undefined, + policyResponseLoading: false, + policyResponseError: undefined, + location: undefined, + policyItems: [], + selectedPolicyId: undefined, + policyItemsLoading: false, + endpointPackageInfo: undefined, + nonExistingPolicies: {}, + agentPolicies: {}, + endpointsExist: true, + patterns: [], + patternsError: undefined, + isAutoRefreshEnabled: true, + autoRefreshInterval: DEFAULT_POLL_INTERVAL, + agentsWithEndpointsTotal: 0, + agentsWithEndpointsTotalError: undefined, + endpointsTotal: 0, + endpointsTotalError: undefined, + queryStrategyVersion: undefined, + policyVersionInfo: undefined, + hostStatus: undefined, + isolationRequestState: createUninitialisedResourceState(), + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 79f0c5af9bbe3..5be67a3581c9e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -41,9 +41,16 @@ describe('EndpointList store concerns', () => { total: 0, loading: false, error: undefined, - details: undefined, - detailsLoading: false, - detailsError: undefined, + endpointDetails: { + activityLog: { + type: 'UninitialisedResourceState', + }, + hostDetails: { + details: undefined, + detailsLoading: false, + detailsError: undefined, + }, + }, policyResponse: undefined, policyResponseLoading: false, policyResponseError: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index c52d922001887..04a04bc38996b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -18,6 +18,7 @@ import { Immutable, HostResultList, HostIsolationResponse, + EndpointAction, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -25,8 +26,9 @@ import { listData } from './selectors'; import { EndpointState } from '../types'; import { endpointListReducer } from './reducer'; import { endpointMiddlewareFactory } from './middleware'; -import { getEndpointListPath } from '../../../common/routing'; +import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing'; import { + createLoadedResourceState, FailedResourceState, isFailedResourceState, isLoadedResourceState, @@ -39,6 +41,7 @@ import { hostIsolationRequestBodyMock, hostIsolationResponseMock, } from '../../../../common/lib/host_isolation/mocks'; +import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; jest.mock('../../policy/store/services/ingest', () => ({ sendGetAgentConfigList: () => Promise.resolve({ items: [] }), @@ -192,4 +195,65 @@ describe('endpoint list middleware', () => { expect(failedAction.error).toBe(apiError); }); }); + + describe('handle ActivityLog State Change actions', () => { + const endpointList = getEndpointListApiResponse(); + const search = getEndpointDetailsPath({ + name: 'endpointDetails', + selected_endpoint: endpointList.hosts[0].metadata.agent.id, + }); + const dispatchUserChangedUrl = () => { + dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/endpoints', + search: `?${search.split('?').pop()}`, + }, + }); + }; + const fleetActionGenerator = new FleetActionGenerator(Math.random().toString()); + const activityLog = [ + fleetActionGenerator.generate({ + agents: [endpointList.hosts[0].metadata.agent.id], + }), + ]; + const dispatchGetActivityLog = () => { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + }; + + it('should set ActivityLog state to loading', async () => { + dispatchUserChangedUrl(); + + const loadingDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isLoadingResourceState(action.payload); + }, + }); + + const loadingDispatchedResponse = await loadingDispatched; + expect(loadingDispatchedResponse.payload.type).toEqual('LoadingResourceState'); + }); + + it('should set ActivityLog state to loaded when fetching activity log is successful', async () => { + dispatchUserChangedUrl(); + + const loadedDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + dispatchGetActivityLog(); + const loadedDispatchedResponse = await loadedDispatched; + const activityLogData = (loadedDispatchedResponse.payload as LoadedResourceState< + EndpointAction[] + >).data; + + expect(activityLogData).toEqual(activityLog); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 9db9932dd4387..90427d5003384 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -7,6 +7,7 @@ import { HttpStart } from 'kibana/public'; import { + EndpointAction, HostInfo, HostIsolationRequestBody, HostIsolationResponse, @@ -18,6 +19,7 @@ import { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../ import { isOnEndpointPage, hasSelectedEndpoint, + selectedAgent, uiQueryParams, listData, endpointPackageInfo, @@ -27,6 +29,7 @@ import { isTransformEnabled, getIsIsolationRequestPending, getCurrentIsolationRequestState, + getActivityLogData, } from './selectors'; import { EndpointState, PolicyIds } from '../types'; import { @@ -37,12 +40,13 @@ import { } from '../../policy/store/services/ingest'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { + ENDPOINT_ACTION_LOG_ROUTE, HOST_METADATA_GET_API, HOST_METADATA_LIST_API, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; -import { resolvePathVariables } from '../../trusted_apps/service/utils'; +import { resolvePathVariables } from '../../../common/utils'; import { createFailedResourceState, createLoadedResourceState, @@ -336,6 +340,29 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(getActivityLogData(getState())), + }); + + try { + const activityLog = await coreStart.http.get( + resolvePathVariables(ENDPOINT_ACTION_LOG_ROUTE, { agent_id: selectedAgent(getState()) }) + ); + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + } catch (error) { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } + // call the policy response api try { const policyResponse = await coreStart.http.get(`/api/endpoint/policy_response`, { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index b2b46e6de9842..19235b792b270 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EndpointDetailsActivityLogChanged } from './action'; import { isOnEndpointPage, hasSelectedEndpoint, @@ -12,52 +13,33 @@ import { getCurrentIsolationRequestState, } from './selectors'; import { EndpointState } from '../types'; +import { initialEndpointPageState } from './builders'; import { AppAction } from '../../../../common/store/actions'; import { ImmutableReducer } from '../../../../common/store'; import { Immutable } from '../../../../../common/endpoint/types'; -import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; import { createUninitialisedResourceState, isUninitialisedResourceState } from '../../../state'; -export const initialEndpointListState: Immutable = { - hosts: [], - pageSize: 10, - pageIndex: 0, - total: 0, - loading: false, - error: undefined, - details: undefined, - detailsLoading: false, - detailsError: undefined, - policyResponse: undefined, - policyResponseLoading: false, - policyResponseError: undefined, - location: undefined, - policyItems: [], - selectedPolicyId: undefined, - policyItemsLoading: false, - endpointPackageInfo: undefined, - nonExistingPolicies: {}, - agentPolicies: {}, - endpointsExist: true, - patterns: [], - patternsError: undefined, - isAutoRefreshEnabled: true, - autoRefreshInterval: DEFAULT_POLL_INTERVAL, - agentsWithEndpointsTotal: 0, - agentsWithEndpointsTotalError: undefined, - endpointsTotal: 0, - endpointsTotalError: undefined, - queryStrategyVersion: undefined, - policyVersionInfo: undefined, - hostStatus: undefined, - isolationRequestState: createUninitialisedResourceState(), -}; +type StateReducer = ImmutableReducer; +type CaseReducer = ( + state: Immutable, + action: Immutable +) => Immutable; -/* eslint-disable-next-line complexity */ -export const endpointListReducer: ImmutableReducer = ( - state = initialEndpointListState, +const handleEndpointDetailsActivityLogChanged: CaseReducer = ( + state, action ) => { + return { + ...state!, + endpointDetails: { + ...state.endpointDetails!, + activityLog: action.payload, + }, + }; +}; + +/* eslint-disable-next-line complexity */ +export const endpointListReducer: StateReducer = (state = initialEndpointPageState(), action) => { if (action.type === 'serverReturnedEndpointList') { const { hosts, @@ -115,18 +97,32 @@ export const endpointListReducer: ImmutableReducer = ( } else if (action.type === 'serverReturnedEndpointDetails') { return { ...state, - details: action.payload.metadata, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + details: action.payload.metadata, + detailsLoading: false, + detailsError: undefined, + }, + }, policyVersionInfo: action.payload.policy_info, hostStatus: action.payload.host_status, - detailsLoading: false, - detailsError: undefined, }; } else if (action.type === 'serverFailedToReturnEndpointDetails') { return { ...state, - detailsError: action.payload, - detailsLoading: false, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: action.payload, + detailsLoading: false, + }, + }, }; + } else if (action.type === 'endpointDetailsActivityLogChanged') { + return handleEndpointDetailsActivityLogChanged(state, action); } else if (action.type === 'serverReturnedPoliciesForOnboarding') { return { ...state, @@ -221,7 +217,6 @@ export const endpointListReducer: ImmutableReducer = ( const stateUpdates: Partial = { location: action.payload, error: undefined, - detailsError: undefined, policyResponseError: undefined, }; @@ -239,6 +234,13 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: undefined, + }, + }, loading: true, policyItemsLoading: true, }; @@ -249,6 +251,14 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsLoading: true, + detailsError: undefined, + }, + }, detailsLoading: true, policyResponseLoading: true, }; @@ -257,8 +267,15 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsLoading: true, + detailsError: undefined, + }, + }, loading: true, - detailsLoading: true, policyResponseLoading: true, policyItemsLoading: true, }; @@ -268,6 +285,13 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: undefined, + }, + }, endpointsExist: true, }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index af95d89fdc10b..8b6599611ffc4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -45,11 +45,16 @@ export const listLoading = (state: Immutable): boolean => state.l export const listError = (state: Immutable) => state.error; -export const detailsData = (state: Immutable) => state.details; +export const detailsData = (state: Immutable) => + state.endpointDetails.hostDetails.details; -export const detailsLoading = (state: Immutable): boolean => state.detailsLoading; +export const detailsLoading = (state: Immutable): boolean => + state.endpointDetails.hostDetails.detailsLoading; -export const detailsError = (state: Immutable) => state.detailsError; +export const detailsError = ( + state: Immutable +): EndpointState['endpointDetails']['hostDetails']['detailsError'] => + state.endpointDetails.hostDetails.detailsError; export const policyItems = (state: Immutable) => state.policyItems; @@ -209,7 +214,12 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if (value === 'policy_response' || value === 'details' || value === 'isolate') { + if ( + value === 'policy_response' || + value === 'details' || + value === 'activity_log' || + value === 'isolate' + ) { data[key] = value; } } else { @@ -240,6 +250,19 @@ export const showView: ( return searchParams.show ?? 'details'; }); +/** + * Returns the selected endpoint's elastic agent Id + * used for fetching endpoint actions log + */ +export const selectedAgent = (state: Immutable): string => { + const hostList = state.hosts; + const { selected_endpoint: selectedEndpoint } = uiQueryParams(state); + return ( + hostList.find((host) => host.metadata.agent.id === selectedEndpoint)?.metadata.elastic.agent + .id || '' + ); +}; + /** * Returns the Host Status which is connected the fleet agent */ @@ -331,3 +354,27 @@ export const getIsolationRequestError: ( return isolateHost.error; } }); + +export const getActivityLogData = ( + state: Immutable +): Immutable => state.endpointDetails.activityLog; + +export const getActivityLogRequestLoading: ( + state: Immutable +) => boolean = createSelector(getActivityLogData, (activityLog) => + isLoadingResourceState(activityLog) +); + +export const getActivityLogRequestLoaded: ( + state: Immutable +) => boolean = createSelector(getActivityLogData, (activityLog) => + isLoadedResourceState(activityLog) +); + +export const getActivityLogError: ( + state: Immutable +) => ServerApiError | undefined = createSelector(getActivityLogData, (activityLog) => { + if (isFailedResourceState(activityLog)) { + return activityLog.error; + } +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index 74eee0602722b..ac06f98004f59 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -14,6 +14,7 @@ import { PolicyData, MetadataQueryStrategyVersions, HostStatus, + EndpointAction, HostIsolationResponse, } from '../../../../common/endpoint/types'; import { ServerApiError } from '../../../common/types'; @@ -34,12 +35,17 @@ export interface EndpointState { loading: boolean; /** api error from retrieving host list */ error?: ServerApiError; - /** details data for a specific host */ - details?: Immutable; - /** details page is retrieving data */ - detailsLoading: boolean; - /** api error from retrieving host details */ - detailsError?: ServerApiError; + endpointDetails: { + activityLog: AsyncResourceState; + hostDetails: { + /** details data for a specific host */ + details?: Immutable; + /** details page is retrieving data */ + detailsLoading: boolean; + /** api error from retrieving host details */ + detailsError?: ServerApiError; + }; + }; /** Holds the Policy Response for the Host currently being displayed in the details */ policyResponse?: HostPolicyResponse; /** policyResponse is being retrieved */ @@ -108,7 +114,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'details' | 'isolate'; + show?: 'policy_response' | 'activity_log' | 'details' | 'isolate'; /** Query text from search bar*/ admin_query?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx new file mode 100644 index 0000000000000..3e228be4565b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx @@ -0,0 +1,78 @@ +/* + * 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 React, { memo, useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; +import { EndpointIndexUIQueryParams } from '../../../types'; +export enum EndpointDetailsTabsTypes { + overview = 'overview', + activityLog = 'activity_log', +} + +export type EndpointDetailsTabsId = + | EndpointDetailsTabsTypes.overview + | EndpointDetailsTabsTypes.activityLog; + +interface EndpointDetailsTabs { + id: string; + name: string; + content: JSX.Element; +} + +const StyledEuiTabbedContent = styled(EuiTabbedContent)` + overflow: hidden; + padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl}; + + > [role='tabpanel'] { + height: 100%; + padding-right: 12px; + overflow: hidden; + overflow-y: auto; + ::-webkit-scrollbar { + -webkit-appearance: none; + width: 4px; + } + ::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: rgba(0, 0, 0, 0.5); + -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); + } + } +`; + +export const EndpointDetailsFlyoutTabs = memo( + ({ show, tabs }: { show: EndpointIndexUIQueryParams['show']; tabs: EndpointDetailsTabs[] }) => { + const [selectedTabId, setSelectedTabId] = useState(() => { + return show === 'details' + ? EndpointDetailsTabsTypes.overview + : EndpointDetailsTabsTypes.activityLog; + }); + + const handleTabClick = useCallback( + (tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EndpointDetailsTabsId), + [setSelectedTabId] + ); + + const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [ + tabs, + selectedTabId, + ]); + + return ( + + ); + } +); + +EndpointDetailsFlyoutTabs.displayName = 'EndpointDetailsFlyoutTabs'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx new file mode 100644 index 0000000000000..de6d2ecf36ecc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -0,0 +1,57 @@ +/* + * 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 React, { memo } from 'react'; + +import { EuiAvatar, EuiComment, EuiText } from '@elastic/eui'; +import { Immutable, EndpointAction } from '../../../../../../../common/endpoint/types'; +import { FormattedDateAndTime } from '../../../../../../common/components/endpoint/formatted_date_time'; +import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme'; + +export const LogEntry = memo( + ({ endpointAction }: { endpointAction: Immutable }) => { + const euiTheme = useEuiTheme(); + const isIsolated = endpointAction?.data.command === 'isolate'; + + // do this better when we can distinguish between endpoint events vs user events + const iconType = endpointAction.user_id === 'sys' ? 'dot' : isIsolated ? 'lock' : 'lockOpen'; + const commentType = endpointAction.user_id === 'sys' ? 'update' : 'regular'; + const timelineIcon = ( + + ); + const event = `${isIsolated ? 'isolated' : 'unisolated'} host`; + const hasComment = !!endpointAction.data.comment; + + return ( + + {hasComment ? ( + +

{endpointAction.data.comment}

+
+ ) : undefined} +
+ ); + } +); + +LogEntry.displayName = 'LogEntry'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx new file mode 100644 index 0000000000000..50c91730e332c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx @@ -0,0 +1,45 @@ +/* + * 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 React, { memo, useCallback } from 'react'; + +import { EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; +import { LogEntry } from './components/log_entry'; +import * as i18 from '../translations'; +import { SearchBar } from '../../../../components/search_bar'; +import { Immutable, EndpointAction } from '../../../../../../common/endpoint/types'; +import { AsyncResourceState } from '../../../../state'; + +export const EndpointActivityLog = memo( + ({ endpointActions }: { endpointActions: AsyncResourceState> }) => { + // TODO + const onSearch = useCallback(() => {}, []); + return ( + <> + + {endpointActions.type !== 'LoadedResourceState' || !endpointActions.data.length ? ( + {'No logged actions'}} + body={

{'No actions have been logged for this endpoint.'}

} + /> + ) : ( + <> + + + {endpointActions.data.map((endpointAction) => ( + + ))} + + )} + + ); + } +); + +EndpointActivityLog.displayName = 'EndpointActivityLog'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index c9db78f425afa..16cae79d42c0f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -258,6 +258,7 @@ export const EndpointDetails = memo( return ( <> + > => ({ + type: 'LoadedResourceState', + data: [ + { + action_id: '1', + '@timestamp': moment().subtract(1, 'hours').fromNow().toString(), + expiration: moment().add(3, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'sys', + data: { + command: 'isolate', + }, + }, + { + action_id: '2', + '@timestamp': moment().subtract(2, 'hours').fromNow().toString(), + expiration: moment().add(1, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'ash', + data: { + command: 'isolate', + comment: 'Sem et tortor consequat id porta nibh venenatis cras sed.', + }, + }, + { + action_id: '3', + '@timestamp': moment().subtract(4, 'hours').fromNow().toString(), + expiration: moment().add(1, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'someone', + data: { + command: 'unisolate', + comment: 'Turpis egestas pretium aenean pharetra.', + }, + }, + { + action_id: '4', + '@timestamp': moment().subtract(1, 'day').fromNow().toString(), + expiration: moment().add(3, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'ash', + data: { + command: 'isolate', + comment: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, \ + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + }, + ], +}); + +export default { + title: 'Endpoints/Endpoint Details', + component: EndpointDetailsFlyout, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export const Tabs = () => ( + {'Endpoint Details'}, + }, + { + id: 'activity_log', + name: 'Activity Log', + content: ActivityLog(), + }, + ]} + /> +); + +export const ActivityLog = () => ( + +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 09b1bbceef21d..8d985f3a4cfe2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import React, { useCallback, useEffect, memo } from 'react'; +import React, { useCallback, useEffect, useMemo, memo } from 'react'; +import styled from 'styled-components'; import { EuiFlyout, EuiFlyoutBody, @@ -16,6 +17,8 @@ import { EuiSpacer, EuiEmptyPrompt, EuiToolTip, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,8 +29,11 @@ import { uiQueryParams, detailsData, detailsError, - showView, detailsLoading, + getActivityLogData, + getActivityLogError, + getActivityLogRequestLoading, + showView, policyResponseConfigurations, policyResponseActions, policyResponseFailedOrWarningActionCount, @@ -39,14 +45,36 @@ import { policyResponseAppliedRevision, } from '../../store/selectors'; import { EndpointDetails } from './endpoint_details'; +import { EndpointActivityLog } from './endpoint_activity_log'; import { PolicyResponse } from './policy_response'; +import * as i18 from '../translations'; import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { + EndpointDetailsFlyoutTabs, + EndpointDetailsTabsTypes, +} from './components/endpoint_details_tabs'; + import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader'; import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; +const DetailsFlyoutBody = styled(EuiFlyoutBody)` + overflow-y: hidden; + flex: 1; + + .euiFlyoutBody__overflow { + overflow: hidden; + mask-image: none; + } + + .euiFlyoutBody__overflowContent { + height: 100%; + display: flex; + } +`; + export const EndpointDetailsFlyout = memo(() => { const history = useHistory(); const toasts = useToasts(); @@ -55,13 +83,51 @@ export const EndpointDetailsFlyout = memo(() => { selected_endpoint: selectedEndpoint, ...queryParamsWithoutSelectedEndpoint } = queryParams; - const details = useEndpointSelector(detailsData); + + const activityLog = useEndpointSelector(getActivityLogData); + const activityLoading = useEndpointSelector(getActivityLogRequestLoading); + const activityError = useEndpointSelector(getActivityLogError); + const hostDetails = useEndpointSelector(detailsData); + const hostDetailsLoading = useEndpointSelector(detailsLoading); + const hostDetailsError = useEndpointSelector(detailsError); + const policyInfo = useEndpointSelector(policyVersionInfo); const hostStatus = useEndpointSelector(hostStatusInfo); - const loading = useEndpointSelector(detailsLoading); - const error = useEndpointSelector(detailsError); const show = useEndpointSelector(showView); + const ContentLoadingMarkup = useMemo( + () => ( + <> + + + + + ), + [] + ); + + const tabs = [ + { + id: EndpointDetailsTabsTypes.overview, + name: i18.OVERVIEW, + content: + hostDetails === undefined ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + { + id: EndpointDetailsTabsTypes.activityLog, + name: i18.ACTIVITY_LOG, + content: activityLoading ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + ]; + const handleFlyoutClose = useCallback(() => { const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; history.push( @@ -73,7 +139,7 @@ export const EndpointDetailsFlyout = memo(() => { }, [history, queryParamsWithoutSelectedEndpoint]); useEffect(() => { - if (error !== undefined) { + if (hostDetailsError !== undefined) { toasts.addDanger({ title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', { defaultMessage: 'Could not find host', @@ -83,7 +149,17 @@ export const EndpointDetailsFlyout = memo(() => { }), }); } - }, [error, toasts]); + if (activityError !== undefined) { + toasts.addDanger({ + title: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorTitle', { + defaultMessage: 'Could not find activity log for host', + }), + text: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorBody', { + defaultMessage: 'Please exit the flyout and select another host with actions.', + }), + }); + } + }, [hostDetailsError, activityError, toasts]); return ( { style={{ zIndex: 4001 }} data-test-subj="endpointDetailsFlyout" size="m" + paddingSize="m" > - {loading ? ( + {hostDetailsLoading || activityLoading ? ( ) : ( - +

- {details?.host?.hostname} + {hostDetails?.host?.hostname}

)}
- {details === undefined ? ( + {hostDetails === undefined ? ( ) : ( <> - {show === 'details' && ( - - - + {(show === 'details' || show === 'activity_log') && ( + + + + + + + )} - {show === 'policy_response' && } + {show === 'policy_response' && } - {show === 'isolate' && } + {show === 'isolate' && } )}
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts new file mode 100644 index 0000000000000..fd2806713183b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -0,0 +1,23 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const OVERVIEW = i18n.translate('xpack.securitySolution.endpointDetails.overview', { + defaultMessage: 'Overview', +}); + +export const ACTIVITY_LOG = i18n.translate('xpack.securitySolution.endpointDetails.activityLog', { + defaultMessage: 'Activity Log', +}); + +export const SEARCH_ACTIVITY_LOG = i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.search', + { + defaultMessage: 'Search activity log', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 5f572251daeda..01bccc81b5063 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -30,7 +30,7 @@ import { GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; -import { resolvePathVariables } from './utils'; +import { resolvePathVariables } from '../../../common/utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts deleted file mode 100644 index c2067f9d0848f..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 { resolvePathVariables } from './utils'; - -describe('utils', () => { - describe('resolvePathVariables', () => { - it('should resolve defined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( - '/segment1/value1/segment2' - ); - }); - - it('should not resolve undefined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should ignore unused variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should replace multiple variable occurences', () => { - expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( - '/value1/segment1/value1' - ); - }); - - it('should replace multiple variables', () => { - const path = resolvePathVariables('/{var1}/segment1/{var2}', { - var1: 'value1', - var2: 'value2', - }); - - expect(path).toBe('/value1/segment1/value2'); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts deleted file mode 100644 index 89067e575665d..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * 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. - */ - -export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => - Object.keys(variables).reduce((acc, paramName) => { - return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); - }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 3f02d505daea1..dc0032243312f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -31,7 +31,7 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; -import { resolvePathVariables } from '../service/utils'; +import { resolvePathVariables } from '../../../common/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index bf8cd416a3e39..25c7c87c6f5c9 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -19,14 +19,12 @@ import { import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; import { ManagementState } from '../types'; -import { - endpointListReducer, - initialEndpointListState, -} from '../pages/endpoint_hosts/store/reducer'; +import { endpointListReducer } from '../pages/endpoint_hosts/store/reducer'; import { initialTrustedAppsPageState } from '../pages/trusted_apps/store/builders'; import { trustedAppsPageReducer } from '../pages/trusted_apps/store/reducer'; import { initialEventFiltersPageState } from '../pages/event_filters/store/builders'; import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer'; +import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -35,7 +33,7 @@ const immutableCombineReducers: ImmutableCombineReducers = combineReducers; */ export const mockManagementState: Immutable = { [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), - [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointListState, + [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(), [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState(), [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(), }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts new file mode 100644 index 0000000000000..487ee16558fec --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts @@ -0,0 +1,30 @@ +/* + * 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 { ENDPOINT_ACTION_LOG_ROUTE } from '../../../../common/endpoint/constants'; +import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { actionsLogRequestHandler } from './audit_log_handler'; + +import { SecuritySolutionPluginRouter } from '../../../types'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers the endpoint activity_log route + */ +export function registerActionAuditLogRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + router.get( + { + path: ENDPOINT_ACTION_LOG_ROUTE, + validate: EndpointActionLogRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + actionsLogRequestHandler(endpointContext) + ); +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts new file mode 100644 index 0000000000000..fdbb9608463e9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts @@ -0,0 +1,59 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { RequestHandler } from 'kibana/server'; +import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; +import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions'; + +import { SecuritySolutionRequestHandlerContext } from '../../../types'; +import { EndpointAppContext } from '../../types'; + +export const actionsLogRequestHandler = ( + endpointContext: EndpointAppContext +): RequestHandler< + TypeOf, + unknown, + unknown, + SecuritySolutionRequestHandlerContext +> => { + const logger = endpointContext.logFactory.get('audit_log'); + return async (context, req, res) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + let result; + try { + result = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + body: { + query: { + match: { + agents: req.params.agent_id, + }, + }, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }); + } catch (error) { + logger.error(error); + throw error; + } + if (result?.statusCode !== 200) { + logger.error(`Error fetching actions log for agent_id ${req.params.agent_id}`); + throw new Error(`Error fetching actions log for agent_id ${req.params.agent_id}`); + } + + return res.ok({ + body: result.body.hits.hits.map((e) => e._source), + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index 9dec4fb2cbb79..e95a33253034d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -6,3 +6,4 @@ */ export * from './isolation'; +export * from './audit_log'; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 2507475592e88..732ae48223421 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -75,7 +75,10 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; -import { registerHostIsolationRoutes } from './endpoint/routes/actions'; +import { + registerHostIsolationRoutes, + registerActionAuditLogRoutes, +} from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -291,6 +294,7 @@ export class Plugin implements IPlugin { + validateConnector: (): Promise => { return { errors: {} }; }, - validateParams: (actionParams: ServerLogActionParams): ValidationResult => { + validateParams: (actionParams: ServerLogActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: null, @@ -929,10 +929,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to email', } ), - validateConnector: (action: EmailActionConnector): ValidationResult => { + validateConnector: (action: EmailActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: EmailActionParams): ValidationResult => { + validateParams: (actionParams: EmailActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: EmailActionConnectorFields, @@ -967,10 +967,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to Slack', } ), - validateConnector: (action: SlackActionConnector): ValidationResult => { + validateConnector: (action: SlackActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: SlackActionParams): ValidationResult => { + validateParams: (actionParams: SlackActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: SlackActionFields, @@ -1000,12 +1000,12 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Index data into Elasticsearch.', } ), - validateConnector: (): ValidationResult => { + validateConnector: (): Promise => { return { errors: {} }; }, actionConnectorFields: IndexActionConnectorFields, actionParamsFields: IndexParamsFields, - validateParams: (): ValidationResult => { + validateParams: (): Promise => { return { errors: {} }; }, }; @@ -1046,10 +1046,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send a request to a web service.', } ), - validateConnector: (action: WebhookActionConnector): ValidationResult => { + validateConnector: (action: WebhookActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: WebhookActionParams): ValidationResult => { + validateParams: (actionParams: WebhookActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: WebhookActionConnectorFields, @@ -1086,10 +1086,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to PagerDuty', } ), - validateConnector: (action: PagerDutyActionConnector): ValidationResult => { + validateConnector: (action: PagerDutyActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: PagerDutyActionParams): ValidationResult => { + validateParams: (actionParams: PagerDutyActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: PagerDutyActionConnectorFields, @@ -1113,8 +1113,8 @@ Each action type should be defined as an `ActionTypeModel` object with the follo iconClass: IconType; selectMessage: string; actionTypeTitle?: string; - validateConnector: (connector: any) => ValidationResult; - validateParams: (actionParams: any) => ValidationResult; + validateConnector: (connector: any) => Promise; + validateParams: (actionParams: any) => Promise; actionConnectorFields: React.FunctionComponent | null; actionParamsFields: React.LazyExoticComponent>>; ``` @@ -1186,7 +1186,7 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Example Action', } ), - validateConnector: (action: ExampleActionConnector): ValidationResult => { + validateConnector: (action: ExampleActionConnector): Promise => { const validationResult = { errors: {} }; const errors = { someConnectorField: new Array(), @@ -1204,7 +1204,7 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - validateParams: (actionParams: ExampleActionParams): ValidationResult => { + validateParams: (actionParams: ExampleActionParams): Promise => { const validationResult = { errors: {} }; const errors = { message: new Array(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx index bebddba0c1110..4d669ab4c76a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -49,7 +49,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -66,7 +66,7 @@ describe('connector validation', () => { }); }); - test('connector validation succeeds when connector config is valid with empty user/password', () => { + test('connector validation succeeds when connector config is valid with empty user/password', async () => { const actionConnector = { secrets: { user: null, @@ -85,7 +85,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -101,7 +101,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -116,7 +116,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -132,7 +132,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when user specified but not password', () => { + test('connector validation fails when user specified but not password', async () => { const actionConnector = { secrets: { user: 'user', @@ -151,7 +151,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -167,7 +167,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when password specified but not user', () => { + test('connector validation fails when password specified but not user', async () => { const actionConnector = { secrets: { user: null, @@ -186,7 +186,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -205,7 +205,7 @@ describe('connector validation', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { to: [], cc: ['test1@test.com'], @@ -213,7 +213,7 @@ describe('action params validation', () => { subject: 'test', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { to: [], cc: [], @@ -224,13 +224,13 @@ describe('action params validation', () => { }); }); - test('action params validation fails when action params is not valid', () => { + test('action params validation fails when action params is not valid', async () => { const actionParams = { to: ['test@test.com'], subject: 'test', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { to: [], cc: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx index 81eadda4fc278..5e23754621430 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx @@ -31,9 +31,12 @@ export function getActionType(): ActionTypeModel, EmailSecrets> => { + ): Promise< + ConnectorValidationResult, EmailSecrets> + > => { + const translations = await import('./translations'); const configErrors = { from: new Array(), port: new Array(), @@ -49,74 +52,25 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const errors = { to: new Array(), cc: new Array(), @@ -146,35 +101,16 @@ export function getActionType(): ActionTypeModel 0; + const isHostInvalid: boolean = + host !== undefined && errors.host !== undefined && errors.host.length > 0; + const isPortInvalid: boolean = + port !== undefined && errors.port !== undefined && errors.port.length > 0; + + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; return ( <> @@ -46,7 +57,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="from" fullWidth error={errors.from} - isInvalid={errors.from.length > 0 && from !== undefined} + isInvalid={isFromInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', { @@ -65,7 +76,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && from !== undefined} + isInvalid={isFromInvalid} name="from" value={from || ''} data-test-subj="emailFromInput" @@ -87,7 +98,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailHost" fullWidth error={errors.host} - isInvalid={errors.host.length > 0 && host !== undefined} + isInvalid={isHostInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', { @@ -98,7 +109,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && host !== undefined} + isInvalid={isHostInvalid} name="host" value={host || ''} data-test-subj="emailHostInput" @@ -121,7 +132,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< fullWidth placeholder="587" error={errors.port} - isInvalid={errors.port.length > 0 && port !== undefined} + isInvalid={isPortInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', { @@ -131,7 +142,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< > 0 && port !== undefined} + isInvalid={isPortInvalid} fullWidth readOnly={readOnly} name="port" @@ -221,7 +232,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailUser" fullWidth error={errors.user} - isInvalid={errors.user.length > 0 && user !== undefined} + isInvalid={isUserInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', { @@ -231,7 +242,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< > 0 && user !== undefined} + isInvalid={isUserInvalid} name="user" readOnly={readOnly} value={user || ''} @@ -252,7 +263,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailPassword" fullWidth error={errors.password} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', { @@ -263,7 +274,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && password !== undefined} + isInvalid={isPasswordInvalid} name="password" value={password || ''} data-test-subj="emailPasswordInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx index e2d6237af85da..5d19a1958c1c6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx @@ -44,13 +44,18 @@ export const EmailParamsFields = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultMessage]); - + const isToInvalid: boolean = to !== undefined && errors.to !== undefined && errors.to.length > 0; + const isSubjectInvalid: boolean = + subject !== undefined && errors.subject !== undefined && errors.subject.length > 0; + const isCCInvalid: boolean = errors.cc !== undefined && errors.cc.length > 0 && cc !== undefined; + const isBCCInvalid: boolean = + errors.bcc !== undefined && errors.bcc.length > 0 && bcc !== undefined; return ( <> 0 && to !== undefined} + isInvalid={isToInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', { @@ -82,7 +87,7 @@ export const EmailParamsFields = ({ > 0 && to !== undefined} + isInvalid={isToInvalid} fullWidth data-test-subj="toEmailAddressInput" selectedOptions={toOptions} @@ -112,7 +117,7 @@ export const EmailParamsFields = ({ 0 && cc !== undefined} + isInvalid={isCCInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', { @@ -122,7 +127,7 @@ export const EmailParamsFields = ({ > 0 && cc !== undefined} + isInvalid={isCCInvalid} fullWidth data-test-subj="ccEmailAddressInput" selectedOptions={ccOptions} @@ -153,7 +158,7 @@ export const EmailParamsFields = ({ 0 && bcc !== undefined} + isInvalid={isBCCInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', { @@ -163,7 +168,7 @@ export const EmailParamsFields = ({ > 0 && bcc !== undefined} + isInvalid={isBCCInvalid} fullWidth data-test-subj="bccEmailAddressInput" selectedOptions={bccOptions} @@ -193,7 +198,7 @@ export const EmailParamsFields = ({ 0 && subject !== undefined} + isInvalid={isSubjectInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', { @@ -207,7 +212,7 @@ export const EmailParamsFields = ({ messageVariables={messageVariables} paramsProperty={'subject'} inputTargetValue={subject} - errors={errors.subject as string[]} + errors={(errors.subject ?? []) as string[]} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts new file mode 100644 index 0000000000000..5da9145ecec0b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts @@ -0,0 +1,78 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const SENDER_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', + { + defaultMessage: 'Sender is required.', + } +); + +export const SENDER_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', + { + defaultMessage: 'Sender is not a valid email address.', + } +); + +export const PORT_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', + { + defaultMessage: 'Port is required.', + } +); + +export const HOST_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', + { + defaultMessage: 'Host is required.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER_USED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const TO_CC_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', + { + defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } +); + +export const SUBJECT_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', + { + defaultMessage: 'Subject is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx index 9757653043175..f43d883be7add 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('index connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -43,7 +43,7 @@ describe('index connector validation', () => { }, } as EsIndexActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { index: [], @@ -57,7 +57,7 @@ describe('index connector validation', () => { }); describe('index connector validation with minimal config', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -68,7 +68,7 @@ describe('index connector validation with minimal config', () => { }, } as EsIndexActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { index: [], @@ -82,9 +82,9 @@ describe('index connector validation with minimal config', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params are valid', () => { + test('action params validation succeeds when action params are valid', async () => { expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{ test: 1234 }], }) ).toEqual({ @@ -95,7 +95,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{ test: 1234 }], indexOverride: 'kibana-alert-history-anything', }) @@ -107,8 +107,8 @@ describe('action params validation', () => { }); }); - test('action params validation fails when action params are invalid', () => { - expect(actionTypeModel.validateParams({})).toEqual({ + test('action params validation fails when action params are invalid', async () => { + expect(await actionTypeModel.validateParams({})).toEqual({ errors: { documents: ['Document is required and should be a valid JSON object.'], indexOverride: [], @@ -116,7 +116,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], }) ).toEqual({ @@ -127,7 +127,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], indexOverride: 'kibana-alert-history-', }) @@ -139,7 +139,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], indexOverride: 'this.is-a_string', }) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx index f4b8284c8cfa6..80d38bda22ab3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx @@ -31,44 +31,32 @@ export function getActionType(): ActionTypeModel, unknown> => { + ): Promise, unknown>> => { + const translations = await import('./translations'); const configErrors = { index: new Array(), }; const validationResult = { config: { errors: configErrors }, secrets: { errors: {} } }; if (!action.config.index) { - configErrors.index.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', - { - defaultMessage: 'Index is required.', - } - ) - ); + configErrors.index.push(translations.INDEX_REQUIRED); } return validationResult; }, actionConnectorFields: lazy(() => import('./es_index_connector')), actionParamsFields: lazy(() => import('./es_index_params')), - validateParams: ( + validateParams: async ( actionParams: IndexActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { documents: new Array(), indexOverride: new Array(), }; const validationResult = { errors }; if (!actionParams.documents?.length || Object.keys(actionParams.documents[0]).length === 0) { - errors.documents.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', - { - defaultMessage: 'Document is required and should be a valid JSON object.', - } - ) - ); + errors.documents.push(translations.DOCUMENT_NOT_VALID); } if (actionParams.indexOverride) { if (!actionParams.indexOverride.startsWith(ALERT_HISTORY_PREFIX)) { @@ -85,14 +73,7 @@ export function getActionType(): ActionTypeModel 0 && index !== undefined; return ( <> @@ -95,7 +97,7 @@ const IndexActionConnectorFields: React.FunctionComponent< defaultMessage="Index" /> } - isInvalid={errors.index.length > 0 && index !== undefined} + isInvalid={isIndexInvalid} error={errors.index} helpText={ <> @@ -118,7 +120,7 @@ const IndexActionConnectorFields: React.FunctionComponent< singleSelection={{ asPlainText: true }} async isLoading={isIndiciesLoading} - isInvalid={errors.index.length > 0 && index !== undefined} + isInvalid={isIndexInvalid} noSuggestions={!indexOptions.length} options={indexOptions} data-test-subj="connectorIndexesComboBox" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index 6973cdcc7a088..b5985cf724e09 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -117,7 +117,11 @@ export const IndexParamsFields = ({ 0} + isInvalid={ + errors.indexOverride !== undefined && + (errors.indexOverride as string[]) && + errors.indexOverride.length > 0 + } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.preconfiguredIndex', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts new file mode 100644 index 0000000000000..b7dd6ac749909 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts @@ -0,0 +1,29 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const INDEX_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', + { + defaultMessage: 'Index is required.', + } +); + +export const DOCUMENT_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', + { + defaultMessage: 'Document is required and should be a valid JSON object.', + } +); + +export const HISTORY_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.badIndexOverrideSuffix', + { + defaultMessage: 'Alert history index must contain valid suffix.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx index ea1bcf82c314c..857582fa7cdaf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('jira connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { email: 'email', @@ -45,7 +45,7 @@ describe('jira connector validation', () => { }, } as JiraActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('jira connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = ({ secrets: { email: 'user', @@ -72,7 +72,7 @@ describe('jira connector validation', () => { config: {}, } as unknown) as JiraActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -90,22 +90,22 @@ describe('jira connector validation', () => { }); describe('jira action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { subActionParams: { incident: { summary: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { subActionParams: { incident: { summary: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': ['Summary is required.'], 'subActionParams.incident.labels': [], @@ -113,7 +113,7 @@ describe('jira action params validation', () => { }); }); - test('params validation fails when labels contain spaces', () => { + test('params validation fails when labels contain spaces', async () => { const actionParams = { subActionParams: { incident: { summary: 'some title', labels: ['label with spaces'] }, @@ -121,7 +121,7 @@ describe('jira action params validation', () => { }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': ['Labels cannot contain spaces.'], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx index ff7fd026f8e31..8e3424a16c295 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx @@ -6,18 +6,19 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; import { JiraActionConnector, JiraConfig, JiraSecrets, JiraActionParams } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: JiraActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), projectKey: new Array(), @@ -33,41 +34,58 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.config.projectKey) { - configErrors.projectKey = [...configErrors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED]; + configErrors.projectKey = [...configErrors.projectKey, translations.JIRA_PROJECT_KEY_REQUIRED]; } if (!action.secrets.email) { - secretsErrors.email = [...secretsErrors.email, i18n.JIRA_EMAIL_REQUIRED]; + secretsErrors.email = [...secretsErrors.email, translations.JIRA_EMAIL_REQUIRED]; } if (!action.secrets.apiToken) { - secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED]; + secretsErrors.apiToken = [...secretsErrors.apiToken, translations.JIRA_API_TOKEN_REQUIRED]; } return validationResult; }; +export const JIRA_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', + { + defaultMessage: 'Create an incident in Jira.', + } +); + +export const JIRA_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', + { + defaultMessage: 'Jira', + } +); + export function getActionType(): ActionTypeModel { return { id: '.jira', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.JIRA_DESC, - actionTypeTitle: i18n.JIRA_TITLE, + selectMessage: JIRA_DESC, + actionTypeTitle: JIRA_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./jira_connectors')), - validateParams: (actionParams: JiraActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: JiraActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { 'subActionParams.incident.summary': new Array(), 'subActionParams.incident.labels': new Array(), @@ -80,13 +98,13 @@ export function getActionType(): ActionTypeModel label.match(/\s/g))) - errors['subActionParams.incident.labels'].push(i18n.LABELS_WHITE_SPACES); + errors['subActionParams.incident.labels'].push(translations.LABELS_WHITE_SPACES); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx index f2753310d73ae..7aec0a405d0d5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -32,13 +32,17 @@ const JiraConnectorFields: React.FC { const { apiUrl, projectKey } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + apiUrl !== undefined && errors.apiUrl !== undefined && errors.apiUrl.length > 0; const { email, apiToken } = action.secrets; - const isProjectKeyInvalid: boolean = errors.projectKey.length > 0 && projectKey !== undefined; - const isEmailInvalid: boolean = errors.email.length > 0 && email !== undefined; - const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken !== undefined; + const isProjectKeyInvalid: boolean = + projectKey !== undefined && errors.projectKey !== undefined && errors.projectKey.length > 0; + const isEmailInvalid: boolean = + email !== undefined && errors.email !== undefined && errors.email.length > 0; + const isApiTokenInvalid: boolean = + apiToken !== undefined && errors.apiToken !== undefined && errors.apiToken.length > 0; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 11123a81440bb..5897de46f94df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -186,6 +186,7 @@ const JiraParamsFields: React.FunctionComponent 0 && incident.labels !== undefined; @@ -277,6 +278,7 @@ const JiraParamsFields: React.FunctionComponent 0 && incident.summary !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts index 4577e55260d9d..5904eb05c31b6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts @@ -7,20 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const JIRA_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', - { - defaultMessage: 'Create an incident in Jira.', - } -); - -export const JIRA_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', - { - defaultMessage: 'Jira', - } -); - export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.apiUrlTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx index eae8690dbdd98..d96ca76aea3be 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('pagerduty connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { routingKey: 'test', @@ -43,7 +43,7 @@ describe('pagerduty connector validation', () => { }, } as PagerDutyActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: [], @@ -53,7 +53,7 @@ describe('pagerduty connector validation', () => { delete actionConnector.config.apiUrl; actionConnector.secrets.routingKey = 'test1'; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: [], @@ -62,7 +62,7 @@ describe('pagerduty connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -73,7 +73,7 @@ describe('pagerduty connector validation', () => { }, } as PagerDutyActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: ['An integration key / routing key is required.'], @@ -84,7 +84,7 @@ describe('pagerduty connector validation', () => { }); describe('pagerduty action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { eventAction: 'trigger', dedupKey: 'test', @@ -97,7 +97,7 @@ describe('pagerduty action params validation', () => { class: 'test class', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { dedupKey: [], summary: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx index 310c5cae24566..80dd360d620b2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx @@ -42,9 +42,10 @@ export function getActionType(): ActionTypeModel< defaultMessage: 'Send to PagerDuty', } ), - validateConnector: ( + validateConnector: async ( action: PagerDutyActionConnector - ): ConnectorValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { routingKey: new Array(), }; @@ -53,22 +54,16 @@ export function getActionType(): ActionTypeModel< }; if (!action.secrets.routingKey) { - secretsErrors.routingKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', - { - defaultMessage: 'An integration key / routing key is required.', - } - ) - ); + secretsErrors.routingKey.push(translations.INTEGRATION_KEY_REQUIRED); } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: PagerDutyActionParams - ): GenericValidationResult< - Pick + ): Promise< + GenericValidationResult> > => { + const translations = await import('./translations'); const errors = { summary: new Array(), timestamp: new Array(), @@ -79,27 +74,13 @@ export function getActionType(): ActionTypeModel< !actionParams.dedupKey?.length && (actionParams.eventAction === 'resolve' || actionParams.eventAction === 'acknowledge') ) { - errors.dedupKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText', - { - defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.', - } - ) - ); + errors.dedupKey.push(translations.DEDUP_KEY_REQUIRED); } if ( actionParams.eventAction === EventActionOptions.TRIGGER && !actionParams.summary?.length ) { - errors.summary.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', - { - defaultMessage: 'Summary is required.', - } - ) - ); + errors.summary.push(translations.SUMMARY_REQUIRED); } if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) { if (isNaN(Date.parse(actionParams.timestamp))) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx index 7e9a5770c2158..3ac7832d0462e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -20,6 +20,9 @@ const PagerDutyActionConnectorFields: React.FunctionComponent< const { docLinks } = useKibana().services; const { apiUrl } = action.config; const { routingKey } = action.secrets; + const isRoutingKeyInvalid: boolean = + routingKey !== undefined && errors.routingKey !== undefined && errors.routingKey.length > 0; + return ( <> } error={errors.routingKey} - isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} + isInvalid={isRoutingKeyInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', { @@ -80,7 +83,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent< )} 0 && routingKey !== undefined} + isInvalid={isRoutingKeyInvalid} name="routingKey" readOnly={readOnly} value={routingKey || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx index 4961a27fd0ac1..8605832b92ea5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx @@ -101,6 +101,12 @@ const PagerDutyParamsFields: React.FunctionComponent 0; + const isSummaryInvalid: boolean = + errors.summary !== undefined && errors.summary.length > 0 && summary !== undefined; + const isTimestampInvalid: boolean = + errors.timestamp !== undefined && errors.timestamp.length > 0 && timestamp !== undefined; + return ( <> @@ -132,7 +138,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0} + isInvalid={isDedupKeyInvalid} label={ isDedupeKeyRequired ? i18n.translate( @@ -166,7 +172,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0 && summary !== undefined} + isInvalid={isSummaryInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel', { @@ -180,7 +186,7 @@ const PagerDutyParamsFields: React.FunctionComponent @@ -211,7 +217,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0 && timestamp !== undefined} + isInvalid={isTimestampInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.timestampTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts new file mode 100644 index 0000000000000..a907b19a1d733 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts @@ -0,0 +1,29 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const SUMMARY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', + { + defaultMessage: 'Summary is required.', + } +); + +export const DEDUP_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText', + { + defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.', + } +); + +export const INTEGRATION_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', + { + defaultMessage: 'An integration key / routing key is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx index 892ab97b8627f..93fb419f509bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('resilient connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { apiKeyId: 'email', @@ -45,7 +45,7 @@ describe('resilient connector validation', () => { }, } as ResilientActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('resilient connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = ({ secrets: { apiKeyId: 'user', @@ -72,7 +72,7 @@ describe('resilient connector validation', () => { config: {}, } as unknown) as ResilientActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -90,22 +90,22 @@ describe('resilient connector validation', () => { }); describe('resilient action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { subActionParams: { incident: { name: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.name': [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { subActionParams: { incident: { name: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.name': ['Name is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx index e7074b7506e7a..f20204af17697 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx @@ -6,6 +6,7 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, @@ -17,12 +18,12 @@ import { ResilientSecrets, ResilientActionParams, } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: ResilientActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), orgId: new Array(), @@ -38,32 +39,49 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.config.orgId) { - configErrors.orgId = [...configErrors.orgId, i18n.ORG_ID_REQUIRED]; + configErrors.orgId = [...configErrors.orgId, translations.ORG_ID_REQUIRED]; } if (!action.secrets.apiKeyId) { - secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, i18n.API_KEY_ID_REQUIRED]; + secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, translations.API_KEY_ID_REQUIRED]; } if (!action.secrets.apiKeySecret) { - secretsErrors.apiKeySecret = [...secretsErrors.apiKeySecret, i18n.API_KEY_SECRET_REQUIRED]; + secretsErrors.apiKeySecret = [ + ...secretsErrors.apiKeySecret, + translations.API_KEY_SECRET_REQUIRED, + ]; } return validationResult; }; +export const DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText', + { + defaultMessage: 'Create an incident in IBM Resilient.', + } +); + +export const TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.actionTypeTitle', + { + defaultMessage: 'Resilient', + } +); + export function getActionType(): ActionTypeModel< ResilientConfig, ResilientSecrets, @@ -72,11 +90,14 @@ export function getActionType(): ActionTypeModel< return { id: '.resilient', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.DESC, - actionTypeTitle: i18n.TITLE, + selectMessage: DESC, + actionTypeTitle: TITLE, validateConnector, actionConnectorFields: lazy(() => import('./resilient_connectors')), - validateParams: (actionParams: ResilientActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: ResilientActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { 'subActionParams.incident.name': new Array(), }; @@ -88,7 +109,7 @@ export function getActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.name?.length ) { - errors['subActionParams.incident.name'].push(i18n.NAME_REQUIRED); + errors['subActionParams.incident.name'].push(translations.NAME_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx index 6996062899c39..1270f19820f4c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -30,14 +30,19 @@ const ResilientConnectorFields: React.FC { const { apiUrl, orgId } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + apiUrl !== undefined && errors.apiUrl !== undefined && errors.apiUrl.length > 0; const { apiKeyId, apiKeySecret } = action.secrets; - const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId !== undefined; - const isApiKeyInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId !== undefined; + const isOrgIdInvalid: boolean = + orgId !== undefined && errors.orgId !== undefined && errors.orgId.length > 0; + const isApiKeyInvalid: boolean = + apiKeyId !== undefined && errors.apiKeyId !== undefined && errors.apiKeyId.length > 0; const isApiKeySecretInvalid: boolean = - errors.apiKeySecret.length > 0 && apiKeySecret !== undefined; + apiKeySecret !== undefined && + errors.apiKeySecret !== undefined && + errors.apiKeySecret.length > 0; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 4642226d40222..54a138a2bc7cf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -213,7 +213,9 @@ const ResilientParamsFields: React.FunctionComponent 0 && incident.name !== undefined + errors['subActionParams.incident.name'] !== undefined && + errors['subActionParams.incident.name'].length > 0 && + incident.name !== undefined } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.nameFieldLabel', @@ -226,7 +228,7 @@ const ResilientParamsFields: React.FunctionComponent { }); describe('server-log connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector: UserConfiguredActionConnector<{}, {}> = { secrets: {}, id: 'test', @@ -39,7 +39,7 @@ describe('server-log connector validation', () => { isPreconfigured: false, }; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -51,23 +51,23 @@ describe('server-log connector validation', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'test message', level: 'trace', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx index 4550d2d65b9df..066c5c0a2f385 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx @@ -30,12 +30,12 @@ export function getActionType(): ActionTypeModel => { - return { config: { errors: {} }, secrets: { errors: {} } }; + validateConnector: (): Promise> => { + return Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }); }, validateParams: ( actionParams: ServerLogActionParams - ): GenericValidationResult> => { + ): Promise>> => { const errors = { message: new Array(), }; @@ -50,7 +50,7 @@ export function getActionType(): ActionTypeModel import('./server_log_params')), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index 02ecab47ae49a..e25e8120b1650 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { describe('servicenow connector validation', () => { [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { - test(`${id}: connector validation succeeds when connector config is valid`, () => { + test(`${id}: connector validation succeeds when connector config is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = { secrets: { @@ -46,7 +46,7 @@ describe('servicenow connector validation', () => { }, } as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('servicenow connector validation', () => { }); }); - test(`${id}: connector validation fails when connector config is not valid`, () => { + test(`${id}: connector validation fails when connector config is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = ({ secrets: { @@ -73,7 +73,7 @@ describe('servicenow connector validation', () => { config: {}, } as unknown) as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -92,24 +92,24 @@ describe('servicenow connector validation', () => { describe('servicenow action params validation', () => { [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { - test(`${id}: action params validation succeeds when action params is valid`, () => { + test(`${id}: action params validation succeeds when action params is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: [] }, }); }); - test(`${id}: params validation fails when body is not valid`, () => { + test(`${id}: params validation fails when body is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: ['Short description is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index a6cc116d3d7b4..24e2a87d42357 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -6,6 +6,7 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, @@ -18,12 +19,12 @@ import { ServiceNowITSMActionParams, ServiceNowSIRActionParams, } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: ServiceNowActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), }; @@ -38,28 +39,56 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.secrets.username) { - secretsErrors.username = [...secretsErrors.username, i18n.USERNAME_REQUIRED]; + secretsErrors.username = [...secretsErrors.username, translations.USERNAME_REQUIRED]; } if (!action.secrets.password) { - secretsErrors.password = [...secretsErrors.password, i18n.PASSWORD_REQUIRED]; + secretsErrors.password = [...secretsErrors.password, translations.PASSWORD_REQUIRED]; } return validationResult; }; +export const SERVICENOW_ITSM_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow ITSM.', + } +); + +export const SERVICENOW_SIR_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow SecOps.', + } +); + +export const SERVICENOW_ITSM_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', + { + defaultMessage: 'ServiceNow ITSM', + } +); + +export const SERVICENOW_SIR_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', + { + defaultMessage: 'ServiceNow SecOps', + } +); + export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowConfig, ServiceNowSecrets, @@ -68,13 +97,14 @@ export function getServiceNowITSMActionType(): ActionTypeModel< return { id: '.servicenow', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.SERVICENOW_ITSM_DESC, - actionTypeTitle: i18n.SERVICENOW_ITSM_TITLE, + selectMessage: SERVICENOW_ITSM_DESC, + actionTypeTitle: SERVICENOW_ITSM_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: ( + validateParams: async ( actionParams: ServiceNowITSMActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -87,7 +117,7 @@ export function getServiceNowITSMActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.short_description?.length ) { - errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } return validationResult; }, @@ -103,11 +133,14 @@ export function getServiceNowSIRActionType(): ActionTypeModel< return { id: '.servicenow-sir', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.SERVICENOW_SIR_DESC, - actionTypeTitle: i18n.SERVICENOW_SIR_TITLE, + selectMessage: SERVICENOW_SIR_DESC, + actionTypeTitle: SERVICENOW_SIR_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: ServiceNowSIRActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -120,7 +153,7 @@ export function getServiceNowSIRActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.short_description?.length ) { - errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index e7b2c4bac5914..c9aafc58f3ede 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -32,12 +32,15 @@ const ServiceNowConnectorFields: React.FC< const { docLinks } = useKibana().services; const { apiUrl } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; const { username, password } = action.secrets; - const isUsernameInvalid: boolean = errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = errors.password.length > 0 && password !== undefined; + const isUsernameInvalid: boolean = + errors.username !== undefined && errors.username.length > 0 && username !== undefined; + const isPasswordInvalid: boolean = + errors.password !== undefined && errors.password.length > 0 && password !== undefined; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index dbd6fec3dad19..f0fc5ed42d24c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -240,6 +240,7 @@ const ServiceNowParamsFields: React.FunctionComponent< fullWidth error={errors['subActionParams.incident.short_description']} isInvalid={ + errors['subActionParams.incident.short_description'] !== undefined && errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index be6756b1c1049..a991ee29c85f8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -151,6 +151,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< fullWidth error={errors['subActionParams.incident.short_description']} isInvalid={ + errors['subActionParams.incident.short_description'] !== undefined && errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 288b6e629112d..ea646b896f5e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -7,34 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const SERVICENOW_ITSM_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow ITSM.', - } -); - -export const SERVICENOW_SIR_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow SecOps.', - } -); - -export const SERVICENOW_ITSM_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', - { - defaultMessage: 'ServiceNow ITSM', - } -); - -export const SERVICENOW_SIR_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', - { - defaultMessage: 'ServiceNow SecOps', - } -); - export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx index eabb63567ea86..dbdc123e0098f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('slack connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { webhookUrl: 'https:\\test', @@ -41,7 +41,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -53,7 +53,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - no webhook url', () => { + test('connector validation fails when connector config is not valid - no webhook url', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -62,7 +62,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -74,7 +74,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook protocol', () => { + test('connector validation fails when connector config is not valid - invalid webhook protocol', async () => { const actionConnector = { secrets: { webhookUrl: 'http:\\test', @@ -85,7 +85,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -97,7 +97,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url', () => { + test('connector validation fails when connector config is not valid - invalid webhook url', async () => { const actionConnector = { secrets: { webhookUrl: 'h', @@ -108,7 +108,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -122,22 +122,22 @@ describe('slack connector validation', () => { }); describe('slack action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { + test('if action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx index 30e60a6ac0156..d3df034a90bf2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx @@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { webhookUrl: new Array(), }; const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } }; if (!action.secrets.webhookUrl) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED); } else if (action.secrets.webhookUrl) { if (!isValidUrl(action.secrets.webhookUrl)) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText', - { - defaultMessage: 'Webhook URL is invalid.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID); } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText', - { - defaultMessage: 'Webhook URL must start with https://.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID); } } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: SlackActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { message: new Array(), }; const validationResult = { errors }; if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); + errors.message.push(translations.MESSAGE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index ce6cda1294adc..e87b00dca9343 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -19,6 +19,8 @@ const SlackActionFields: React.FunctionComponent< > = ({ action, editActionSecrets, errors, readOnly }) => { const { docLinks } = useKibana().services; const { webhookUrl } = action.secrets; + const isWebhookUrlInvalid: boolean = + errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined; return ( <> @@ -34,7 +36,7 @@ const SlackActionFields: React.FunctionComponent< } error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', { @@ -54,7 +56,7 @@ const SlackActionFields: React.FunctionComponent< )} 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} name="webhookUrl" readOnly={readOnly} value={webhookUrl || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx index 3aa7fd8227496..59e10277cfe08 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx @@ -49,7 +49,7 @@ const SlackParamsFields: React.FunctionComponent ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts new file mode 100644 index 0000000000000..bd1fd8ea194f6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts @@ -0,0 +1,36 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const WEBHOOK_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } +); + +export const WEBHOOK_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); + +export const WEBHOOK_URL_HTTP_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx index 62be20a9bad90..641c46af6bfc1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('teams connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { webhookUrl: 'https:\\test', @@ -40,7 +40,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -52,7 +52,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - empty webhook url', () => { + test('connector validation fails when connector config is not valid - empty webhook url', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -61,7 +61,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -73,7 +73,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url', () => { + test('connector validation fails when connector config is not valid - invalid webhook url', async () => { const actionConnector = { secrets: { webhookUrl: 'h', @@ -84,7 +84,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -96,7 +96,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url protocol', () => { + test('connector validation fails when connector config is not valid - invalid webhook url protocol', async () => { const actionConnector = { secrets: { webhookUrl: 'http://insecure', @@ -107,7 +107,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -121,22 +121,22 @@ describe('teams connector validation', () => { }); describe('teams action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { + test('if action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx index e8c7be7311c1c..c48b4f950855d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx @@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { webhookUrl: new Array(), }; const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } }; if (!action.secrets.webhookUrl) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED); } else if (action.secrets.webhookUrl) { if (!isValidUrl(action.secrets.webhookUrl)) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', - { - defaultMessage: 'Webhook URL is invalid.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID); } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', - { - defaultMessage: 'Webhook URL must start with https://.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID); } } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: TeamsActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { message: new Array(), }; const validationResult = { errors }; if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); + errors.message.push(translations.MESSAGE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx index 454b938692225..8de1c68926f14 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx @@ -20,6 +20,9 @@ const TeamsActionFields: React.FunctionComponent< const { webhookUrl } = action.secrets; const { docLinks } = useKibana().services; + const isWebhookUrlInvalid: boolean = + errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined; + return ( <> } error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.webhookUrlTextFieldLabel', { @@ -54,7 +57,7 @@ const TeamsActionFields: React.FunctionComponent< )} 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} name="webhookUrl" readOnly={readOnly} value={webhookUrl || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx index c0a20e214b4e1..0aea576c10b31 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx @@ -40,7 +40,7 @@ const TeamsParamsFields: React.FunctionComponent ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts new file mode 100644 index 0000000000000..790a3b3bac32f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts @@ -0,0 +1,36 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const WEBHOOK_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } +); + +export const WEBHOOK_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); + +export const WEBHOOK_URL_HTTP_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts new file mode 100644 index 0000000000000..3550121e81694 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts @@ -0,0 +1,64 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } +); + +export const URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const METHOD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', + { + defaultMessage: 'Method is required.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const USERNAME_REQUIRED_FOR_PASSWORD = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText', + { + defaultMessage: 'Username is required when password is used.', + } +); + +export const BODY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', + { + defaultMessage: 'Body is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx index 8399316044f33..3e42e7965c5bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('webhook connector validation', () => { - test('connector validation succeeds when hasAuth is true and connector config is valid', () => { + test('connector validation succeeds when hasAuth is true and connector config is valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -48,7 +48,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: [], @@ -64,7 +64,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation succeeds when hasAuth is false and connector config is valid', () => { + test('connector validation succeeds when hasAuth is false and connector config is valid', async () => { const actionConnector = { secrets: { user: '', @@ -82,7 +82,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: [], @@ -98,7 +98,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -112,7 +112,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: ['URL is required.'], @@ -128,7 +128,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation fails when url in config is not valid', () => { + test('connector validation fails when url in config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -144,7 +144,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: ['URL is invalid.'], @@ -162,22 +162,22 @@ describe('webhook connector validation', () => { }); describe('webhook action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { body: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { body: [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { body: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { body: ['Body is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx index 3ba801b83c46c..a668f531a6d4c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx @@ -40,9 +40,12 @@ export function getActionType(): ActionTypeModel< defaultMessage: 'Webhook data', } ), - validateConnector: ( + validateConnector: async ( action: WebhookActionConnector - ): ConnectorValidationResult, WebhookSecrets> => { + ): Promise< + ConnectorValidationResult, WebhookSecrets> + > => { + const translations = await import('./translations'); const configErrors = { url: new Array(), method: new Array(), @@ -56,95 +59,39 @@ export function getActionType(): ActionTypeModel< secrets: { errors: secretsErrors }, }; if (!action.config.url) { - configErrors.url.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', - { - defaultMessage: 'URL is required.', - } - ) - ); + configErrors.url.push(translations.URL_REQUIRED); } if (action.config.url && !isValidUrl(action.config.url)) { - configErrors.url = [ - ...configErrors.url, - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField', - { - defaultMessage: 'URL is invalid.', - } - ), - ]; + configErrors.url = [...configErrors.url, translations.URL_INVALID]; } if (!action.config.method) { - configErrors.method.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', - { - defaultMessage: 'Method is required.', - } - ) - ); + configErrors.method.push(translations.METHOD_REQUIRED); } if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) { - secretsErrors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText', - { - defaultMessage: 'Username is required.', - } - ) - ); + secretsErrors.user.push(translations.USERNAME_REQUIRED); } if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) { - secretsErrors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText', - { - defaultMessage: 'Password is required.', - } - ) - ); + secretsErrors.password.push(translations.PASSWORD_REQUIRED); } if (action.secrets.user && !action.secrets.password) { - secretsErrors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', - { - defaultMessage: 'Password is required when username is used.', - } - ) - ); + secretsErrors.password.push(translations.PASSWORD_REQUIRED_FOR_USER); } if (!action.secrets.user && action.secrets.password) { - secretsErrors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText', - { - defaultMessage: 'Username is required when password is used.', - } - ) - ); + secretsErrors.user.push(translations.USERNAME_REQUIRED_FOR_PASSWORD); } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: WebhookActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { body: new Array(), }; const validationResult = { errors }; validationResult.errors = errors; if (!actionParams.body?.length) { - errors.body.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', - { - defaultMessage: 'Body is required.', - } - ) - ); + errors.body.push(translations.BODY_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx index d3231f52b4d7b..ba0e7016caa76 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx @@ -76,7 +76,11 @@ const WebhookActionConnectorFields: React.FunctionComponent< ) ); } - const hasHeaderErrors = headerErrors.keyHeader.length > 0 || headerErrors.valueHeader.length > 0; + const hasHeaderErrors: boolean = + (headerErrors.keyHeader !== undefined && + headerErrors.valueHeader !== undefined && + headerErrors.keyHeader.length > 0) || + headerErrors.valueHeader.length > 0; function addHeader() { if (headers && !!Object.keys(headers).find((key) => key === httpHeaderKey)) { @@ -219,6 +223,13 @@ const WebhookActionConnectorFields: React.FunctionComponent< ); }); + const isUrlInvalid: boolean = + errors.url !== undefined && errors.url.length > 0 && url !== undefined; + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; + return ( <> @@ -248,7 +259,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="url" fullWidth error={errors.url} - isInvalid={errors.url.length > 0 && url !== undefined} + isInvalid={isUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.urlTextFieldLabel', { @@ -258,7 +269,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< > 0 && url !== undefined} + isInvalid={isUrlInvalid} fullWidth readOnly={readOnly} value={url || ''} @@ -326,7 +337,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="webhookUser" fullWidth error={errors.user} - isInvalid={errors.user.length > 0 && user !== undefined} + isInvalid={isUserInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.userTextFieldLabel', { @@ -336,7 +347,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< > 0 && user !== undefined} + isInvalid={isUserInvalid} name="user" readOnly={readOnly} value={user || ''} @@ -357,7 +368,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="webhookPassword" fullWidth error={errors.password} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.passwordTextFieldLabel', { @@ -369,7 +380,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< fullWidth name="password" readOnly={readOnly} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} value={password || ''} data-test-subj="webhookPasswordInput" onChange={(e) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 964f538d54971..091ea1e305e35 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -23,12 +23,12 @@ describe('action_connector_form', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, }); actionTypeRegistry.get.mockReturnValue(actionType); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 0790dce9ca3d4..29232940da5c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -51,11 +51,11 @@ export function validateBaseProperties( return validationResult; } -export function getConnectorErrors( +export async function getConnectorErrors( connector: UserConfiguredActionConnector, actionTypeModel: ActionTypeModel ) { - const connectorValidationResult = actionTypeModel?.validateConnector(connector); + const connectorValidationResult = await actionTypeModel?.validateConnector(connector); const configErrors = (connectorValidationResult.config ? connectorValidationResult.config.errors : {}) as IErrorObject; @@ -173,7 +173,8 @@ export const ActionConnectorForm = ({ ); const FieldsComponent = actionTypeRegistered.actionConnectorFields; - + const isNameInvalid: boolean = + connector.name !== undefined && errors.name !== undefined && errors.name.length > 0; return ( } - isInvalid={errors.name.length > 0 && connector.name !== undefined} + isInvalid={isNameInvalid} error={errors.name} > 0 && connector.name !== undefined} + isInvalid={isNameInvalid} name="name" placeholder="Untitled" data-test-subj="nameInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index ad727be58280f..bedde696e51c0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -53,12 +53,12 @@ describe('action_form', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -68,12 +68,12 @@ describe('action_form', () => { id: 'disabled-by-config', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -83,12 +83,12 @@ describe('action_form', () => { id: '.jira', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): ValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -98,12 +98,12 @@ describe('action_form', () => { id: 'disabled-by-license', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -113,12 +113,12 @@ describe('action_form', () => { id: 'preconfigured', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index e9f79633ef520..f12ce25abc492 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -30,7 +30,7 @@ import { ActionTypeRegistryContract, } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; -import { ActionTypeForm, ActionTypeFormProps } from './action_type_form'; +import { ActionTypeForm } from './action_type_form'; import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; @@ -357,49 +357,42 @@ export const ActionForm = ({ ); } - const actionParamsErrors: ActionTypeFormProps['actionParamsErrors'] = actionTypeRegistry - .get(actionItem.actionTypeId) - ?.validateParams(actionItem.params); - return ( - - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] }); - setAddModalVisibility(true); - }} - onConnectorSelected={(id: string) => { - setActionIdByIndex(id, index); - }} - actionTypeRegistry={actionTypeRegistry} - onDeleteAction={() => { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setActions(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id) - .length === 0 - ); - setActiveActionItem(undefined); - }} - /> - - + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] }); + setAddModalVisibility(true); + }} + onConnectorSelected={(id: string) => { + setActionIdByIndex(id, index); + }} + actionTypeRegistry={actionTypeRegistry} + onDeleteAction={() => { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setActions(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === + 0 + ); + setActiveActionItem(undefined); + }} + /> ); })} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx index 38f1e8f52254c..e8590595b9d61 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx @@ -43,12 +43,12 @@ describe('action_type_form', () => { id: '.pagerduty', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -92,12 +92,12 @@ describe('action_type_form', () => { id: '.pagerduty', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -220,7 +220,6 @@ function getActionTypeForm( onAddConnector={onAddConnector ?? jest.fn()} onDeleteAction={onDeleteAction ?? jest.fn()} onConnectorSelected={onConnectorSelected ?? jest.fn()} - actionParamsErrors={{ errors: { summary: [], timestamp: [], dedupKey: [] } }} defaultActionGroupId={defaultActionGroupId ?? 'default'} setActionParamsProperty={jest.fn()} index={index ?? 1} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 2690aeaffad32..526d899b7efb1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -47,9 +47,6 @@ import { DefaultActionParams } from '../../lib/get_defaults_for_action_params'; export type ActionTypeFormProps = { actionItem: AlertAction; actionConnector: ActionConnector; - actionParamsErrors: { - errors: IErrorObject; - }; index: number; onAddConnector: () => void; onConnectorSelected: (id: string) => void; @@ -80,7 +77,6 @@ const preconfiguredMessage = i18n.translate( export const ActionTypeForm = ({ actionItem, actionConnector, - actionParamsErrors, index, onAddConnector, onConnectorSelected, @@ -106,6 +102,9 @@ export const ActionTypeForm = ({ const selectedActionGroup = actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup; const [actionGroup, setActionGroup] = useState(); + const [actionParamsErrors, setActionParamsErrors] = useState<{ errors: IErrorObject }>({ + errors: {}, + }); useEffect(() => { setAvailableActionVariables( @@ -130,6 +129,16 @@ export const ActionTypeForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionGroup]); + useEffect(() => { + (async () => { + const res: { errors: IErrorObject } = await actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); + setActionParamsErrors(res); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionItem]); + const canSave = hasSaveActionsCapability(capabilities); const getSelectedOptions = (actionItemId: string) => { const selectedConnector = connectors.find((connector) => connector.id === actionItemId); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 9a011823612c4..e15916138af71 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -40,12 +40,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -77,12 +77,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -114,12 +114,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index fedb2ed382994..8dbe5f105a0f7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -198,12 +198,12 @@ function createActionType() { id: `my-action-type-${++count}`, iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index d3a6d662720ca..1a3a186d891cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState, useReducer } from 'react'; +import React, { useCallback, useState, useReducer, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -30,7 +30,9 @@ import { ActionType, ActionConnector, UserConfiguredActionConnector, + IErrorObject, ConnectorAddFlyoutProps, + ActionTypeModel, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -38,6 +40,7 @@ import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { createConnectorReducer, InitialConnector, ConnectorReducer } from './connector_reducer'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; const ConnectorAddFlyout: React.FunctionComponent = ({ onClose, @@ -47,7 +50,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ consumer, actionTypeRegistry, }) => { - let hasErrors = false; + const [hasErrors, setHasErrors] = useState(true); + let actionTypeModel: ActionTypeModel | undefined; + const { http, notifications: { toasts }, @@ -55,7 +60,17 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } = useKibana().services; const [actionType, setActionType] = useState(undefined); const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState(false); - + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); // hooks const initialConnector: InitialConnector, Record> = { actionTypeId: actionType?.id ?? '', @@ -73,6 +88,24 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ Record >, }); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + if (actionTypeModel) { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connector, actionType]); const setActionProperty = ( key: Key, @@ -101,7 +134,6 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } let currentForm; - let actionTypeModel; let saveButton; if (!actionType) { currentForm = ( @@ -115,22 +147,12 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } else { actionTypeModel = actionTypeRegistry.get(actionType.id); - const { - configErrors, - connectorBaseErrors, - connectorErrors, - secretsErrors, - } = getConnectorErrors(connector, actionTypeModel); - hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - currentForm = ( @@ -170,9 +192,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; @@ -235,13 +257,13 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ - {actionTypeModel && actionTypeModel.iconClass ? ( + {!!actionTypeModel && actionTypeModel.iconClass ? ( ) : null} - {actionTypeModel && actionType ? ( + {!!actionTypeModel && actionType ? ( <>

@@ -280,7 +302,17 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ ) } > - {currentForm} + <> + {currentForm} + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + @@ -314,7 +346,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ - {canSave && actionTypeModel && actionType ? saveButton : null} + {canSave && !!actionTypeModel && actionType ? saveButton : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index c18f6955d1217..1ae37cf96cd3e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -39,12 +39,12 @@ describe('connector_add_modal', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index d01ee08df2394..1e9669d1995dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useReducer, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiModal, @@ -19,6 +19,7 @@ import { EuiFlexItem, EuiIcon, EuiFlexGroup, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionConnectorForm, getConnectorErrors } from './action_connector_form'; @@ -31,9 +32,11 @@ import { ActionConnector, ActionTypeRegistryContract, UserConfiguredActionConnector, + IErrorObject, } from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type ConnectorAddModalProps = { @@ -56,7 +59,7 @@ const ConnectorAddModal = ({ notifications: { toasts }, application: { capabilities }, } = useKibana().services; - let hasErrors = false; + const [hasErrors, setHasErrors] = useState(true); const initialConnector: InitialConnector< Record, Record @@ -69,6 +72,7 @@ const ConnectorAddModal = ({ [actionType.id] ); const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); const canSave = hasSaveActionsCapability(capabilities); const reducer: ConnectorReducer< @@ -81,6 +85,34 @@ const ConnectorAddModal = ({ Record >, }); + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); + + const actionTypeModel = actionTypeRegistry.get(actionType.id); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + })(); + }, [connector, actionTypeModel]); + const setConnector = (value: any) => { dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } }); }; @@ -97,15 +129,6 @@ const ConnectorAddModal = ({ onClose(); }, [initialConnector, onClose]); - const actionTypeModel = actionTypeRegistry.get(actionType.id); - const { configErrors, connectorBaseErrors, connectorErrors, secretsErrors } = getConnectorErrors( - connector, - actionTypeModel - ); - hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - const onActionConnectorSave = async (): Promise => await createActionConnector({ http, connector }) .then((savedConnector) => { @@ -157,15 +180,25 @@ const ConnectorAddModal = ({ - + <> + + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + @@ -189,9 +222,9 @@ const ConnectorAddModal = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 56bf57cb45095..e6d3c0bde8113 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -51,12 +51,12 @@ describe('connector_edit_flyout', () => { id: 'test-action-type-id', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -95,12 +95,12 @@ describe('connector_edit_flyout', () => { id: 'test-action-type-id', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 66a4dcc452c51..ca729f9a61662 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useReducer, useState } from 'react'; +import React, { useCallback, useReducer, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -23,6 +23,7 @@ import { EuiLink, EuiTabs, EuiTab, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Option, none, some } from 'fp-ts/lib/Option'; @@ -31,6 +32,7 @@ import { TestConnectorForm } from './test_connector_form'; import { ActionConnector, ConnectorEditFlyoutProps, + IErrorObject, EditConectorTabs, UserConfiguredActionConnector, } from '../../../types'; @@ -44,6 +46,7 @@ import { import './connector_edit_flyout.scss'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; const ConnectorEditFlyout = ({ initialConnector, @@ -53,12 +56,14 @@ const ConnectorEditFlyout = ({ consumer, actionTypeRegistry, }: ConnectorEditFlyoutProps) => { + const [hasErrors, setHasErrors] = useState(true); const { http, notifications: { toasts }, docLinks, application: { capabilities }, } = useKibana().services; + const getConnectorWithoutSecrets = () => ({ ...(initialConnector as UserConfiguredActionConnector< Record, @@ -75,6 +80,35 @@ const ConnectorEditFlyout = ({ const [{ connector }, dispatch] = useReducer(reducer, { connector: getConnectorWithoutSecrets(), }); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); + + const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + })(); + }, [connector, actionTypeModel]); + const [isSaving, setIsSaving] = useState(false); const [selectedTab, setTab] = useState(tab); @@ -113,25 +147,6 @@ const ConnectorEditFlyout = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [onClose]); - const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); - const { - configErrors, - connectorBaseErrors, - connectorErrors, - secretsErrors, - } = !connector.isPreconfigured - ? getConnectorErrors(connector, actionTypeModel) - : { - configErrors: {}, - connectorBaseErrors: {}, - connectorErrors: {}, - secretsErrors: {}, - }; - - const hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - const onActionConnectorSave = async (): Promise => await updateActionConnector({ http, connector, id: connector.id }) .then((savedConnector) => { @@ -227,9 +242,9 @@ const ConnectorEditFlyout = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; @@ -286,19 +301,29 @@ const ConnectorEditFlyout = ({ {selectedTab === EditConectorTabs.Configuration ? ( !connector.isPreconfigured ? ( - { - setHasChanges(true); - // if the user changes the connector, "forget" the last execution - // so the user comes back to a clean form ready to run a fresh test - setTestExecutionResult(none); - dispatch(changes); - }} - actionTypeRegistry={actionTypeRegistry} - consumer={consumer} - /> + <> + { + setHasChanges(true); + // if the user changes the connector, "forget" the last execution + // so the user comes back to a clean form ready to run a fresh test + setTestExecutionResult(none); + dispatch(changes); + }} + actionTypeRegistry={actionTypeRegistry} + consumer={consumer} + /> + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + ) : ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index 5cdc15ab0375d..ae15670ce8ab9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -53,12 +53,12 @@ const actionType = { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 92a17a2e4cfae..242c1c33d8d79 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Suspense } from 'react'; +import React, { Suspense, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -46,11 +46,18 @@ export const TestConnectorForm = ({ isExecutingAction, actionTypeRegistry, }: ConnectorAddFlyoutProps) => { + const [actionErrors, setActionErrors] = useState({}); + const [hasErrors, setHasErrors] = useState(false); const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); const ParamsFieldsComponent = actionTypeModel.actionParamsFields; - const actionErrors = actionTypeModel?.validateParams(actionParams).errors as IErrorObject; - const hasErrors = !!Object.values(actionErrors).find((errors) => errors.length > 0); + useEffect(() => { + (async () => { + const res = (await actionTypeModel?.validateParams(actionParams)).errors as IErrorObject; + setActionErrors({ ...res }); + setHasErrors(!!Object.values(res).find((errors) => errors.length > 0)); + })(); + }, [actionTypeModel, actionParams]); const steps = [ { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 7b6453e705ec3..90eadaf5f9b8b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -162,12 +162,12 @@ describe('actions_connectors_list component with items', () => { id: 'test', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index cb43c168aa999..b40b7cbc1a387 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -135,12 +135,12 @@ describe('alert_add', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index a40f77998d6ee..2d111d5405230 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -15,9 +15,10 @@ import { AlertTypeParams, AlertUpdates, AlertFlyoutCloseReason, + IErrorObject, AlertAddProps, } from '../../../types'; -import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; +import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer'; import { createAlert } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; @@ -102,6 +103,18 @@ const AlertAdd = ({ } }, [alert.params, initialAlertParams, setInitialAlertParams]); + const [alertActionsErrors, setAlertActionsErrors] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry); + setIsLoading(false); + setAlertActionsErrors([...res]); + })(); + }, [alert, actionTypeRegistry]); + const checkForChangesAndCloseFlyout = () => { if ( hasAlertChanged(alert, initialAlert, false) || @@ -125,9 +138,8 @@ const AlertAdd = ({ }; const alertType = alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null; - const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - actionTypeRegistry, alertType ); @@ -195,9 +207,10 @@ const AlertAdd = ({ { setIsSaving(true); - if (!isValidAlert(alert, alertErrors, alertActionsErrors)) { + if (isLoading || !isValidAlert(alert, alertErrors, alertActionsErrors)) { setAlert( getAlertWithInvalidatedFields( alert as Alert, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx index fe4b9d066429d..ee36257dedf0b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx @@ -13,17 +13,25 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, + EuiLoadingSpinner, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useHealthContext } from '../../context/health_context'; interface AlertAddFooterProps { isSaving: boolean; + isFormLoading: boolean; onSave: () => void; onCancel: () => void; } -export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterProps) => { +export const AlertAddFooter = ({ + isSaving, + onSave, + onCancel, + isFormLoading, +}: AlertAddFooterProps) => { const { loadingHealthCheck } = useHealthContext(); return ( @@ -36,6 +44,14 @@ export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterPro })} + {isFormLoading ? ( + + + + + ) : ( + <> + )} { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index f6569f32088ee..bf6f0ef43b820 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useReducer, useState } from 'react'; +import React, { useReducer, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -20,11 +20,12 @@ import { EuiPortal, EuiCallOut, EuiSpacer, + EuiLoadingSpinner, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Alert, AlertEditProps, AlertFlyoutCloseReason } from '../../../types'; -import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; +import { Alert, AlertFlyoutCloseReason, AlertEditProps, IErrorObject } from '../../../types'; +import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; import { alertReducer, ConcreteAlertReducer } from './alert_reducer'; import { updateAlert } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; @@ -53,6 +54,8 @@ export const AlertEdit = ({ false ); const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState(false); + const [alertActionsErrors, setAlertActionsErrors] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { http, @@ -64,9 +67,17 @@ export const AlertEdit = ({ const alertType = alertTypeRegistry.get(alert.alertTypeId); - const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry); + setAlertActionsErrors([...res]); + setIsLoading(false); + })(); + }, [alert, actionTypeRegistry]); + + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - actionTypeRegistry, alertType ); @@ -80,7 +91,11 @@ export const AlertEdit = ({ async function onSaveAlert(): Promise { try { - if (isValidAlert(alert, alertErrors, alertActionsErrors) && !hasActionsWithBrokenConnector) { + if ( + !isLoading && + isValidAlert(alert, alertErrors, alertActionsErrors) && + !hasActionsWithBrokenConnector + ) { const newAlert = await updateAlert({ http, alert, id: alert.id }); toasts.addSuccess( i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { @@ -177,6 +192,14 @@ export const AlertEdit = ({ )} + {isLoading ? ( + + + + + ) : ( + <> + )} { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return { + validateConnector: (): Promise> => { + return Promise.resolve({ config: { errors: {}, }, secrets: { errors: {}, }, - }; + }); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index b4b6477fd5947..16878abc362d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -121,11 +121,7 @@ export function validateBaseProperties(alertObject: InitialAlert): ValidationRes return validationResult; } -export function getAlertErrors( - alert: Alert, - actionTypeRegistry: ActionTypeRegistryContract, - alertTypeModel: AlertTypeModel | null -) { +export function getAlertErrors(alert: Alert, alertTypeModel: AlertTypeModel | null) { const alertParamsErrors: IErrorObject = alertTypeModel ? alertTypeModel.validate(alert.params).errors : []; @@ -135,18 +131,26 @@ export function getAlertErrors( ...alertBaseErrors, } as IErrorObject; - const alertActionsErrors = alert.actions.map((alertAction: AlertAction) => { - return actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params) - .errors; - }); return { alertParamsErrors, alertBaseErrors, - alertActionsErrors, alertErrors, }; } +export async function getAlertActionErrors( + alert: Alert, + actionTypeRegistry: ActionTypeRegistryContract +): Promise { + return await Promise.all( + alert.actions.map( + async (alertAction: AlertAction) => + (await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)) + .errors + ) + ); +} + export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) => !!Object.values(errors).find((errorList) => { if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index 8c7876c3f7255..ee561a65069e5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -42,12 +42,12 @@ const getTestActionType = ( id: id || 'my-action-type', iconClass: iconClass || 'test', selectMessage: selectedMessage || 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0f2b961b1f2da..5ddddcb73a843 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -109,10 +109,10 @@ export interface ActionTypeModel - ) => ConnectorValidationResult, Partial>; + ) => Promise, Partial>>; validateParams: ( actionParams: ActionParams - ) => GenericValidationResult | unknown>; + ) => Promise | unknown>>; actionConnectorFields: React.LazyExoticComponent< ComponentType< ActionConnectorFieldsProps> diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap index 41e46259715ee..45e40f71c0fde 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap @@ -51,14 +51,14 @@ exports[`DataOrIndexMissing component renders headingMessage 1`] = `

diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx index 77927b5750ff3..7f9839ff94dbe 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx @@ -43,14 +43,14 @@ export const DataOrIndexMissing = ({ headingMessage, settings }: DataMissingProp

diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx index 5a28c7c2592d7..a6fd6579c49fa 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx @@ -38,7 +38,7 @@ export const EmptyStateComponent = ({ const noIndicesMessage = ( {settings?.heartbeatIndices} }} /> ); diff --git a/x-pack/test/accessibility/apps/advanced_settings.ts b/x-pack/test/accessibility/apps/advanced_settings.ts index 7382577c7ebe6..6f2dc78a7b35b 100644 --- a/x-pack/test/accessibility/apps/advanced_settings.ts +++ b/x-pack/test/accessibility/apps/advanced_settings.ts @@ -11,45 +11,51 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'settings', 'header']); const a11y = getService('a11y'); const testSubjects = getService('testSubjects'); + const toasts = getService('toasts'); - // FLAKY: https://github.com/elastic/kibana/issues/99006 - describe.skip('Stack Management -Advanced Settings', () => { + describe('Stack Management -Advanced Settings', () => { // click on Management > Advanced settings it('click on advanced settings ', async () => { await PageObjects.common.navigateToUrl('management', 'kibana/settings', { shouldUseHashForSubUrl: false, }); await testSubjects.click('settings'); + await toasts.dismissAllToasts(); await a11y.testAppSnapshot(); }); // clicking on the top search bar it('adv settings - search ', async () => { await testSubjects.click('settingsSearchBar'); + await toasts.dismissAllToasts(); await a11y.testAppSnapshot(); }); // clicking on the category dropdown it('adv settings - category -dropdown ', async () => { await testSubjects.click('settingsSearchBar'); + await toasts.dismissAllToasts(); await a11y.testAppSnapshot(); }); // clicking on the toggle button it('adv settings - toggle ', async () => { await testSubjects.click('advancedSetting-editField-csv:quoteValues'); + await toasts.dismissAllToasts(); await a11y.testAppSnapshot(); }); // clicking on editor panel it('adv settings - edit ', async () => { await testSubjects.click('advancedSetting-editField-csv:separator'); + await toasts.dismissAllToasts(); await a11y.testAppSnapshot(); }); // clicking on save button it('adv settings - save', async () => { await testSubjects.click('advancedSetting-saveButton'); + await toasts.dismissAllToasts(); await a11y.testAppSnapshot(); }); }); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index c56e8adfbe34f..548b4d0db1124 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -151,6 +151,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`, '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.alerting.invalidateApiKeysTask.interval="15s"', + '--xpack.alerting.healthCheck.interval="1s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, `--xpack.actions.tls.verificationMode=${verificationMode}`, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts new file mode 100644 index 0000000000000..668de3eb4fb9e --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts @@ -0,0 +1,128 @@ +/* + * 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 { UserAtSpaceScenarios } from '../../scenarios'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + getUrlPrefix, + getTestAlertData, + ObjectRemover, + AlertUtils, + ESTestIndexTool, + ES_TEST_INDEX_NAME, +} from '../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createFindTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + const retry = getService('retry'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + + describe('health', () => { + const objectRemover = new ObjectRemover(supertest); + + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + + after(async () => { + await esTestIndexTool.destroy(); + }); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + + describe(scenario.id, () => { + let alertUtils: AlertUtils; + let indexRecordActionId: string; + + before(async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + indexRecordActionId = createdAction.id; + objectRemover.add(space.id, indexRecordActionId, 'connector', 'actions'); + + alertUtils = new AlertUtils({ + user, + space, + supertestWithoutAuth, + indexRecordActionId, + objectRemover, + }); + }); + + after(() => objectRemover.removeAll()); + + it('should return healthy status by default', async () => { + const { body: health } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerting/_health`) + .auth(user.username, user.password); + expect(health.is_sufficiently_secure).to.eql(true); + expect(health.has_permanent_encryption_key).to.eql(true); + expect(health.alerting_framework_heath.decryption_health.status).to.eql('ok'); + expect(health.alerting_framework_heath.execution_health.status).to.eql('ok'); + expect(health.alerting_framework_heath.read_health.status).to.eql('ok'); + }); + + it('should return error when a rule in the default space is failing', async () => { + const reference = alertUtils.generateReference(); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + schedule: { + interval: '5m', + }, + rule_type_id: 'test.failing', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + }) + ) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const ruleInErrorStatus = await retry.tryForTime(30000, async () => { + const { body: rule } = await supertest + .get(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .expect(200); + expect(rule.execution_status.status).to.eql('error'); + return rule; + }); + + await retry.tryForTime(30000, async () => { + const { body: health } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerting/_health`) + .auth(user.username, user.password); + expect(health.alerting_framework_heath.execution_health.status).to.eql('warn'); + expect(health.alerting_framework_heath.execution_health.timestamp).to.eql( + ruleInErrorStatus.execution_status.last_execution_date + ); + }); + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index b1b52d89997cd..6ca68bd188124 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -51,6 +51,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts')); loadTestFile(require.resolve('./event_log')); loadTestFile(require.resolve('./mustache_templates')); + loadTestFile(require.resolve('./health')); }); }); } diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js index 55b4da8b11ec2..8f40f5826c537 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js @@ -32,7 +32,8 @@ export default function ({ getService }) { const { addPolicyToIndex } = registerIndexHelpers({ supertest }); - describe('policies', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/101219 + describe.skip('policies', () => { after(() => Promise.all([cleanUpEsResources(), cleanUpPolicies()])); describe('list', () => { diff --git a/x-pack/test/api_integration/apis/ml/jobs/categorization_field_examples.ts b/x-pack/test/api_integration/apis/ml/jobs/categorization_field_examples.ts index 85072986f1112..c9adc85b40c1b 100644 --- a/x-pack/test/api_integration/apis/ml/jobs/categorization_field_examples.ts +++ b/x-pack/test/api_integration/apis/ml/jobs/categorization_field_examples.ts @@ -284,8 +284,7 @@ export default ({ getService }: FtrProviderContext) => { }, ]; - // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056 - describe.skip('Categorization example endpoint - ', function () { + describe('Categorization example endpoint - ', function () { before(async () => { await esArchiver.loadIfNeeded('ml/categorization'); await ml.testResources.setKibanaTimeZoneToUTC(); diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts index 6df94c35ab50b..186a87e547382 100644 --- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts @@ -241,34 +241,33 @@ export default ({ getService }: FtrProviderContext) => { dashboards: [] as string[], }, }, - // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056 - // { - // testTitleSuffix: - // 'for logs_ui_categories with prefix, startDatafeed true and estimateModelMemory true', - // sourceDataArchive: 'ml/module_logs', - // indexPattern: { name: 'ft_module_logs', timeField: '@timestamp' }, - // module: 'logs_ui_categories', - // user: USER.ML_POWERUSER, - // requestBody: { - // prefix: 'pf7_', - // indexPatternName: 'ft_module_logs', - // startDatafeed: true, - // end: Date.now(), - // }, - // expected: { - // responseCode: 200, - // jobs: [ - // { - // jobId: 'pf7_log-entry-categories-count', - // jobState: JOB_STATE.CLOSED, - // datafeedState: DATAFEED_STATE.STOPPED, - // }, - // ], - // searches: [] as string[], - // visualizations: [] as string[], - // dashboards: [] as string[], - // }, - // }, + { + testTitleSuffix: + 'for logs_ui_categories with prefix, startDatafeed true and estimateModelMemory true', + sourceDataArchive: 'ml/module_logs', + indexPattern: { name: 'ft_module_logs', timeField: '@timestamp' }, + module: 'logs_ui_categories', + user: USER.ML_POWERUSER, + requestBody: { + prefix: 'pf7_', + indexPatternName: 'ft_module_logs', + startDatafeed: true, + end: Date.now(), + }, + expected: { + responseCode: 200, + jobs: [ + { + jobId: 'pf7_log-entry-categories-count', + jobState: JOB_STATE.CLOSED, + datafeedState: DATAFEED_STATE.STOPPED, + }, + ], + searches: [] as string[], + visualizations: [] as string[], + dashboards: [] as string[], + }, + }, { testTitleSuffix: 'for nginx_ecs with prefix, startDatafeed true and estimateModelMemory true', sourceDataArchive: 'ml/module_nginx', diff --git a/x-pack/test/api_integration/apis/ml/results/get_categorizer_stats.ts b/x-pack/test/api_integration/apis/ml/results/get_categorizer_stats.ts index 32a131ded98e1..ef677969d006f 100644 --- a/x-pack/test/api_integration/apis/ml/results/get_categorizer_stats.ts +++ b/x-pack/test/api_integration/apis/ml/results/get_categorizer_stats.ts @@ -51,8 +51,7 @@ export default ({ getService }: FtrProviderContext) => { query: { bool: { must: [{ match_all: {} }] } }, }; - // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056 - describe.skip('get categorizer_stats', function () { + describe('get categorizer_stats', function () { before(async () => { await esArchiver.loadIfNeeded('ml/module_sample_logs'); await ml.testResources.setKibanaTimeZoneToUTC(); diff --git a/x-pack/test/api_integration/apis/ml/results/get_stopped_partitions.ts b/x-pack/test/api_integration/apis/ml/results/get_stopped_partitions.ts index 97e4800f1bedd..d00999b06b588 100644 --- a/x-pack/test/api_integration/apis/ml/results/get_stopped_partitions.ts +++ b/x-pack/test/api_integration/apis/ml/results/get_stopped_partitions.ts @@ -85,8 +85,7 @@ export default ({ getService }: FtrProviderContext) => { const testJobIds = testSetUps.map((t) => t.jobId); - // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056 - describe.skip('get stopped_partitions', function () { + describe('get stopped_partitions', function () { before(async () => { await esArchiver.loadIfNeeded('ml/module_sample_logs'); await ml.testResources.setKibanaTimeZoneToUTC(); diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index d0466b8814fec..bff0b590a8e68 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 0000000000000..57c2fc194d0c0 --- /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/apps/ml/anomaly_detection/advanced_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts index 1ff437d441aa8..bc2f327720690 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts @@ -149,73 +149,72 @@ export default function ({ getService }: FtrProviderContext) { }, }, }, - // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056 - // { - // suiteTitle: 'with categorization detector and default datafeed settings', - // jobSource: 'ft_ecommerce', - // jobId: `ec_advanced_2_${Date.now()}`, - // get jobIdClone(): string { - // return `${this.jobId}_clone`; - // }, - // jobDescription: - // 'Create advanced job from ft_ecommerce dataset with a categorization detector and default datafeed settings', - // jobGroups: ['automated', 'ecommerce', 'advanced'], - // get jobGroupsClone(): string[] { - // return [...this.jobGroups, 'clone']; - // }, - // pickFieldsConfig: { - // categorizationField: 'products.product_name', - // detectors: [ - // { - // identifier: 'count by mlcategory', - // function: 'count', - // byField: 'mlcategory', - // } as Detector, - // ], - // influencers: ['mlcategory'], - // bucketSpan: '4h', - // memoryLimit: '100mb', - // } as PickFieldsConfig, - // datafeedConfig: {} as DatafeedConfig, - // expected: { - // wizard: { - // timeField: 'order_date', - // }, - // row: { - // recordCount: '4,675', - // memoryStatus: 'ok', - // jobState: 'closed', - // datafeedState: 'stopped', - // latestTimestamp: '2019-07-12 23:45:36', - // }, - // counts: { - // processed_record_count: '4,675', - // processed_field_count: '4,675', - // input_bytes: '354.2 KB', - // input_field_count: '4,675', - // invalid_date_count: '0', - // missing_field_count: '0', - // out_of_order_timestamp_count: '0', - // empty_bucket_count: '0', - // sparse_bucket_count: '0', - // bucket_count: '185', - // earliest_record_timestamp: '2019-06-12 00:04:19', - // latest_record_timestamp: '2019-07-12 23:45:36', - // input_record_count: '4,675', - // latest_bucket_timestamp: '2019-07-12 20:00:00', - // }, - // modelSizeStats: { - // result_type: 'model_size_stats', - // model_bytes_exceeded: '0.0 B', - // // not checking total_by_field_count as the number of categories might change - // total_over_field_count: '0', - // total_partition_field_count: '2', - // bucket_allocation_failures_count: '0', - // memory_status: 'ok', - // timestamp: '2019-07-12 16:00:00', - // }, - // }, - // }, + { + suiteTitle: 'with categorization detector and default datafeed settings', + jobSource: 'ft_ecommerce', + jobId: `ec_advanced_2_${Date.now()}`, + get jobIdClone(): string { + return `${this.jobId}_clone`; + }, + jobDescription: + 'Create advanced job from ft_ecommerce dataset with a categorization detector and default datafeed settings', + jobGroups: ['automated', 'ecommerce', 'advanced'], + get jobGroupsClone(): string[] { + return [...this.jobGroups, 'clone']; + }, + pickFieldsConfig: { + categorizationField: 'products.product_name', + detectors: [ + { + identifier: 'count by mlcategory', + function: 'count', + byField: 'mlcategory', + } as Detector, + ], + influencers: ['mlcategory'], + bucketSpan: '4h', + memoryLimit: '100mb', + } as PickFieldsConfig, + datafeedConfig: {} as DatafeedConfig, + expected: { + wizard: { + timeField: 'order_date', + }, + row: { + recordCount: '4,675', + memoryStatus: 'ok', + jobState: 'closed', + datafeedState: 'stopped', + latestTimestamp: '2019-07-12 23:45:36', + }, + counts: { + processed_record_count: '4,675', + processed_field_count: '4,675', + input_bytes: '354.2 KB', + input_field_count: '4,675', + invalid_date_count: '0', + missing_field_count: '0', + out_of_order_timestamp_count: '0', + empty_bucket_count: '0', + sparse_bucket_count: '0', + bucket_count: '185', + earliest_record_timestamp: '2019-06-12 00:04:19', + latest_record_timestamp: '2019-07-12 23:45:36', + input_record_count: '4,675', + latest_bucket_timestamp: '2019-07-12 20:00:00', + }, + modelSizeStats: { + result_type: 'model_size_stats', + model_bytes_exceeded: '0.0 B', + // not checking total_by_field_count as the number of categories might change + total_over_field_count: '0', + total_partition_field_count: '2', + bucket_allocation_failures_count: '0', + memory_status: 'ok', + timestamp: '2019-07-12 16:00:00', + }, + }, + }, ]; const calendarId = `wizard-test-calendar_${Date.now()}`; diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts index 144199136a6dc..85eeacc58514e 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts @@ -74,8 +74,7 @@ export default function ({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; - // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056 - describe.skip('categorization', function () { + describe('categorization', function () { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('ml/categorization'); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index b16944cd73060..0b88ecca247c5 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 () => { diff --git a/x-pack/test/functional/services/ml/test_resources.ts b/x-pack/test/functional/services/ml/test_resources.ts index a8db7ccb7a764..a857809a3079f 100644 --- a/x-pack/test/functional/services/ml/test_resources.ts +++ b/x-pack/test/functional/services/ml/test_resources.ts @@ -534,7 +534,7 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider }, async installFleetPackage(packageIdentifier: string) { - log.debug(`Installing Fleet package'${packageIdentifier}'`); + log.debug(`Installing Fleet package '${packageIdentifier}'`); await supertest .post(`/api/fleet/epm/packages/${packageIdentifier}`) @@ -545,7 +545,7 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider }, async removeFleetPackage(packageIdentifier: string) { - log.debug(`Removing Fleet package'${packageIdentifier}'`); + log.debug(`Removing Fleet package '${packageIdentifier}'`); await supertest .delete(`/api/fleet/epm/packages/${packageIdentifier}`) diff --git a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js index 31eb6d65ce7ac..fb881162f51e8 100644 --- a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js +++ b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js @@ -85,7 +85,7 @@ export default function ({ getService, getPageObjects }) { await putWatcher(watch, id, body, client, log); }); it('should be successful and increment revision', async () => { - await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime); + await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime.bind(retry)); }); it('should delete watch and update revision', async () => { await deleteWatcher(watch, id, client, log); diff --git a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js index 7e9ced57fdc0b..db913f563ebb0 100644 --- a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js +++ b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js @@ -79,7 +79,7 @@ export default ({ getService, getPageObjects }) => { await putWatcher(watch, id, body, client, log); }); it('should be successful and increment revision', async () => { - await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime); + await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime.bind(retry)); }); it('should delete watch and update revision', async () => { await deleteWatcher(watch, id, client, log);