From 7afae17948b92a4955f6d258262db1465bd88514 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 31 Jan 2022 11:52:51 +0200 Subject: [PATCH 01/65] [Lens] displays custom bounds error for right axis when lower bound is above 0 (#124037) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lens/public/xy_visualization/xy_config_panel/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index 3a757c539f08e..099ea258f0902 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -314,7 +314,7 @@ export const XyToolbar = memo(function XyToolbar( ); const hasBarOrAreaOnRightAxis = Boolean( axisGroups - .find((group) => group.groupId === 'left') + .find((group) => group.groupId === 'right') ?.series?.some((series) => { const seriesType = state.layers.find((l) => l.layerId === series.layer)?.seriesType; return seriesType?.includes('bar') || seriesType?.includes('area'); From 17134697a0012ff1d2c96fb149b73cbfffe47ad7 Mon Sep 17 00:00:00 2001 From: Muhammad Ibragimov <53621505+mibragimov@users.noreply.github.com> Date: Mon, 31 Jan 2022 14:58:13 +0500 Subject: [PATCH 02/65] [Console] Fix autocomplete inserting comma in triple quotes (#123572) * Fix autocomplete inserting comma in triple quotes * Fix inserting commas and flaky test * Fixed problems on triple quotes and single quotes replacement. * Fixed cursor position after adding a comma to the prefix. * Final generic solution for multiple edge cases. Co-authored-by: Muhammad Ibragimov Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Yaroslav Kuznietsov --- .../models/sense_editor/integration.test.js | 21 +--- .../public/lib/autocomplete/autocomplete.ts | 107 +++++++++++++++--- test/functional/apps/console/_console.ts | 18 +-- test/functional/page_objects/console_page.ts | 12 +- 4 files changed, 112 insertions(+), 46 deletions(-) diff --git a/src/plugins/console/public/application/models/sense_editor/integration.test.js b/src/plugins/console/public/application/models/sense_editor/integration.test.js index a342c3429d03d..e60b4175f668f 100644 --- a/src/plugins/console/public/application/models/sense_editor/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/integration.test.js @@ -324,7 +324,7 @@ describe('Integration', () => { ' "field": "something"\n' + ' },\n' + ' "facets": {},\n' + - ' "size": 20 \n' + + ' "size": 20\n' + '}', MAPPING, SEARCH_KB, @@ -357,31 +357,18 @@ describe('Integration', () => { autoCompleteSet: ['facets', 'query', 'size'], }, { - name: 'prefix comma, beginning of line', + name: 'prefix comma, end of line', cursor: { lineNumber: 7, column: 1 }, initialValue: '', addTemplate: true, - prefixToAdd: '', + prefixToAdd: ',\n', suffixToAdd: '', rangeToReplace: { - start: { lineNumber: 7, column: 1 }, + start: { lineNumber: 6, column: 14 }, end: { lineNumber: 7, column: 1 }, }, autoCompleteSet: ['facets', 'query', 'size'], }, - { - name: 'prefix comma, end of line', - cursor: { lineNumber: 6, column: 15 }, - initialValue: '', - addTemplate: true, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 6, column: 15 }, - end: { lineNumber: 6, column: 15 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, ] ); diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index 43fdde3f02349..63d43a849a681 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -335,6 +335,20 @@ export default function ({ }); } + function replaceLinesWithPrefixPieces(prefixPieces: string[], startLineNumber: number) { + const middlePiecesCount = prefixPieces.length - 1; + prefixPieces.forEach((piece, index) => { + if (index >= middlePiecesCount) { + return; + } + const line = startLineNumber + index + 1; + const column = editor.getLineValue(line).length - 1; + const start = { lineNumber: line, column: 0 }; + const end = { lineNumber: line, column }; + editor.replace({ start, end }, piece); + }); + } + function applyTerm(term: { value?: string; context?: AutoCompleteContext; @@ -390,12 +404,36 @@ export default function ({ templateInserted = false; } } + const linesToMoveDown = (context.prefixToAdd ?? '').match(/\n|\r/g)?.length ?? 0; - valueToInsert = context.prefixToAdd + valueToInsert + context.suffixToAdd; + let prefix = context.prefixToAdd ?? ''; // disable listening to the changes we are making. editor.off('changeSelection', editorChangeListener); + // if should add chars on the previous not empty line + if (linesToMoveDown) { + const [firstPart = '', ...prefixPieces] = context.prefixToAdd?.split(/\n|\r/g) ?? []; + const lastPart = _.last(prefixPieces) ?? ''; + const { start } = context.rangeToReplace!; + const end = { ...start, column: start.column + firstPart.length }; + + // adding only the content of prefix before newlines + editor.replace({ start, end }, firstPart); + + // replacing prefix pieces without the last one, which is handled separately + if (prefixPieces.length - 1 > 0) { + replaceLinesWithPrefixPieces(prefixPieces, start.lineNumber); + } + + // and the last prefix line, keeping the editor's own newlines. + prefix = lastPart; + context.rangeToReplace!.start.lineNumber = context.rangeToReplace!.end.lineNumber; + context.rangeToReplace!.start.column = 0; + } + + valueToInsert = prefix + valueToInsert + context.suffixToAdd; + if (context.rangeToReplace!.start.column !== context.rangeToReplace!.end.column) { editor.replace(context.rangeToReplace!, valueToInsert); } else { @@ -410,7 +448,7 @@ export default function ({ column: context.rangeToReplace!.start.column + termAsString.length + - context.prefixToAdd!.length + + prefix.length + (templateInserted ? 0 : context.suffixToAdd!.length), }; @@ -671,6 +709,47 @@ export default function ({ } } + function addCommaToPrefixOnAutocomplete( + nonEmptyToken: Token | null, + context: AutoCompleteContext, + charsToSkipOnSameLine: number = 1 + ) { + if (nonEmptyToken && nonEmptyToken.type.indexOf('url') < 0) { + const { position } = nonEmptyToken; + // if not on the first line + if (context.rangeToReplace && context.rangeToReplace.start?.lineNumber > 1) { + const prevTokenLineNumber = position.lineNumber; + const line = context.editor?.getLineValue(prevTokenLineNumber) ?? ''; + const prevLineLength = line.length; + const linesToEnter = context.rangeToReplace.end.lineNumber - prevTokenLineNumber; + + const isTheSameLine = linesToEnter === 0; + let startColumn = prevLineLength + 1; + let spaces = context.rangeToReplace.start.column - 1; + + if (isTheSameLine) { + // prevent last char line from replacing + startColumn = position.column + charsToSkipOnSameLine; + // one char for pasted " and one for , + spaces = context.rangeToReplace.end.column - startColumn - 2; + } + + // go back to the end of the previous line + context.rangeToReplace = { + start: { lineNumber: prevTokenLineNumber, column: startColumn }, + end: { ...context.rangeToReplace.end }, + }; + + spaces = spaces >= 0 ? spaces : 0; + const spacesToEnter = isTheSameLine ? (spaces === 0 ? 1 : spaces) : spaces; + const newLineChars = `\n`.repeat(linesToEnter >= 0 ? linesToEnter : 0); + const whitespaceChars = ' '.repeat(spacesToEnter); + // add a comma at the end of the previous line, a new line and indentation + context.prefixToAdd = `,${newLineChars}${whitespaceChars}`; + } + } + } + function addBodyPrefixSuffixToContext(context: AutoCompleteContext) { // Figure out what happens next to the token to see whether it needs trailing commas etc. @@ -758,23 +837,19 @@ export default function ({ case 'paren.lparen': case 'punctuation.comma': case 'punctuation.colon': + case 'punctuation.start_triple_quote': case 'method': break; + case 'text': + case 'string': + case 'constant.numeric': + case 'constant.language.boolean': + case 'punctuation.end_triple_quote': + addCommaToPrefixOnAutocomplete(nonEmptyToken, context, nonEmptyToken?.value.length); + break; default: - if (nonEmptyToken && nonEmptyToken.type.indexOf('url') < 0) { - const { position, value } = nonEmptyToken; - - // We can not rely on prefixToAdd here, because it adds a comma at the beginning of the new token - // Since we have access to the position of the previous token here, this could be a good place to insert a comma manually - context.prefixToAdd = ''; - editor.insert( - { - column: position.column + value.length, - lineNumber: position.lineNumber, - }, - ', ' - ); - } + addCommaToPrefixOnAutocomplete(nonEmptyToken, context); + break; } return context; diff --git a/test/functional/apps/console/_console.ts b/test/functional/apps/console/_console.ts index 7a8a36bec56d7..e4ddf5d6bb602 100644 --- a/test/functional/apps/console/_console.ts +++ b/test/functional/apps/console/_console.ts @@ -92,24 +92,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); - // Flaky, see https://github.com/elastic/kibana/issues/123556 - it.skip('should add comma after previous non empty line on autocomplete', async () => { - const LINE_NUMBER = 2; + it('should add comma after previous non empty line on autocomplete', async () => { + const LINE_NUMBER = 4; await PageObjects.console.dismissTutorial(); await PageObjects.console.clearTextArea(); + await PageObjects.console.enterRequest(); + await PageObjects.console.enterText(`{\n\t"query": {\n\t\t"match": {}`); await PageObjects.console.pressEnter(); await PageObjects.console.pressEnter(); await PageObjects.console.pressEnter(); await PageObjects.console.promptAutocomplete(); + await PageObjects.console.pressEnter(); - await retry.try(async () => { - const textOfPreviousNonEmptyLine = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); - log.debug(textOfPreviousNonEmptyLine); - const lastChar = textOfPreviousNonEmptyLine.charAt(textOfPreviousNonEmptyLine.length - 1); - expect(lastChar).to.be.equal(','); - }); + const textOfPreviousNonEmptyLine = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); + log.debug(textOfPreviousNonEmptyLine); + const lastChar = textOfPreviousNonEmptyLine.charAt(textOfPreviousNonEmptyLine.length - 1); + expect(lastChar).to.be.equal(','); }); describe('with a data URI in the load_from query', () => { diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index f8a64c0032bb2..4fdc47756e710 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -110,9 +110,11 @@ export class ConsolePageObject extends FtrService { private async getEditorTextArea() { // This focusses the cursor on the bottom of the text area - const editor = await this.getEditor(); - const content = await editor.findByCssSelector('.ace_content'); - await content.click(); + await this.retry.try(async () => { + const editor = await this.getEditor(); + const content = await editor.findByCssSelector('.ace_content'); + await content.click(); + }); return await this.testSubjects.find('console-textarea'); } @@ -137,6 +139,8 @@ export class ConsolePageObject extends FtrService { public async clearTextArea() { const textArea = await this.getEditorTextArea(); - await textArea.clearValueWithKeyboard(); + await this.retry.try(async () => { + await textArea.clearValueWithKeyboard(); + }); } } From 7c4eea4dfef6b0c9024fec07210a6ed81ff58b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 31 Jan 2022 11:04:49 +0100 Subject: [PATCH 03/65] [Usage Collection] Report succeeded/failed/notReady collectors stats (#123956) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/telemetry/schema/oss_root.json | 56 +++++++++++ .../server/collector/collector_set.test.ts | 93 ++++++++++++++++++- .../server/collector/collector_set.ts | 46 +++++++-- 3 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src/plugins/telemetry/schema/oss_root.json b/src/plugins/telemetry/schema/oss_root.json index e526dc6413916..cf9b881facef2 100644 --- a/src/plugins/telemetry/schema/oss_root.json +++ b/src/plugins/telemetry/schema/oss_root.json @@ -194,6 +194,62 @@ "properties": { "kibana_config_usage": { "type": "pass_through" + }, + "usage_collector_stats": { + "properties": { + "not_ready": { + "properties": { + "count": { + "type": "short" + }, + "names": { + "type": "array", + "items": { + "type": "keyword" + } + } + } + }, + "not_ready_timeout": { + "properties": { + "count": { + "type": "short" + }, + "names": { + "type": "array", + "items": { + "type": "keyword" + } + } + } + }, + "succeeded": { + "properties": { + "count": { + "type": "short" + }, + "names": { + "type": "array", + "items": { + "type": "keyword" + } + } + } + }, + "failed": { + "properties": { + "count": { + "type": "short" + }, + "names": { + "type": "array", + "items": { + "type": "keyword" + } + } + } + } + } } } } diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts index 491937bc29fcb..f6d667f2959d9 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -95,6 +95,15 @@ describe('CollectorSet', () => { type: 'MY_TEST_COLLECTOR', result: { passTest: 1000 }, }, + { + type: 'usage_collector_stats', + result: { + not_ready: { count: 0, names: [] }, + not_ready_timeout: { count: 0, names: [] }, + succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, + failed: { count: 0, names: [] }, + }, + }, ]); }); @@ -115,7 +124,17 @@ describe('CollectorSet', () => { // Do nothing } // This must return an empty object instead of null/undefined - expect(result).toStrictEqual([]); + expect(result).toStrictEqual([ + { + type: 'usage_collector_stats', + result: { + not_ready: { count: 0, names: [] }, + not_ready_timeout: { count: 0, names: [] }, + succeeded: { count: 0, names: [] }, + failed: { count: 1, names: ['MY_TEST_COLLECTOR'] }, + }, + }, + ]); }); it('should not break if isReady is not a function', async () => { @@ -135,6 +154,15 @@ describe('CollectorSet', () => { type: 'MY_TEST_COLLECTOR', result: { test: 1 }, }, + { + type: 'usage_collector_stats', + result: { + not_ready: { count: 0, names: [] }, + not_ready_timeout: { count: 0, names: [] }, + succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, + failed: { count: 0, names: [] }, + }, + }, ]); }); @@ -154,6 +182,15 @@ describe('CollectorSet', () => { type: 'MY_TEST_COLLECTOR', result: { test: 1 }, }, + { + type: 'usage_collector_stats', + result: { + not_ready: { count: 0, names: [] }, + not_ready_timeout: { count: 0, names: [] }, + succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, + failed: { count: 0, names: [] }, + }, + }, ]); }); }); @@ -535,6 +572,31 @@ describe('CollectorSet', () => { "result": Object {}, "type": "ready_col", }, + Object { + "result": Object { + "failed": Object { + "count": 0, + "names": Array [], + }, + "not_ready": Object { + "count": 1, + "names": Array [ + "not_ready_col", + ], + }, + "not_ready_timeout": Object { + "count": 0, + "names": Array [], + }, + "succeeded": Object { + "count": 1, + "names": Array [ + "ready_col", + ], + }, + }, + "type": "usage_collector_stats", + }, ] `); }); @@ -584,6 +646,31 @@ describe('CollectorSet', () => { "result": Object {}, "type": "ready_col", }, + Object { + "result": Object { + "failed": Object { + "count": 0, + "names": Array [], + }, + "not_ready": Object { + "count": 0, + "names": Array [], + }, + "not_ready_timeout": Object { + "count": 1, + "names": Array [ + "timeout_col", + ], + }, + "succeeded": Object { + "count": 1, + "names": Array [ + "ready_col", + ], + }, + }, + "type": "usage_collector_stats", + }, ] `); }); @@ -608,7 +695,7 @@ describe('CollectorSet', () => { esClient: mockEsClient, soClient: mockSoClient, }); - expect(results).toHaveLength(1); + expect(results).toHaveLength(2); }); it('adds extra context to collectors with extendFetchContext config', async () => { @@ -634,7 +721,7 @@ describe('CollectorSet', () => { soClient: mockSoClient, kibanaRequest: request, }); - expect(results).toHaveLength(1); + expect(results).toHaveLength(2); }); }); }); diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 05ebb2a34202c..79fb0a39c7801 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -31,6 +31,15 @@ interface CollectorSetConfig { maximumWaitTimeForAllCollectorsInS?: number; collectors?: AnyCollector[]; } + +// Schema manually added in src/plugins/telemetry/schema/oss_root.json under `stack_stats.kibana.plugins.usage_collector_stats` +interface CollectorStats { + not_ready: { count: number; names: string[] }; + not_ready_timeout: { count: number; names: string[] }; + succeeded: { count: number; names: string[] }; + failed: { count: number; names: string[] }; +} + export class CollectorSet { private readonly logger: Logger; private readonly maximumWaitTimeForAllCollectorsInS: number; @@ -101,7 +110,11 @@ export class CollectorSet { private getReadyCollectors = async ( collectors: Map = this.collectors - ): Promise => { + ): Promise<{ + readyCollectors: AnyCollector[]; + nonReadyCollectorTypes: string[]; + timedOutCollectorsTypes: string[]; + }> => { if (!(collectors instanceof Map)) { throw new Error( `getReadyCollectors method given bad Map of collectors: ` + typeof collectors @@ -162,7 +175,11 @@ export class CollectorSet { .filter(({ isReadyWithTimeout }) => isReadyWithTimeout.value === true) .map(({ collector }) => collector); - return readyCollectors; + return { + readyCollectors, + nonReadyCollectorTypes: collectorsTypesNotReady, + timedOutCollectorsTypes, + }; }; public bulkFetch = async ( @@ -172,7 +189,16 @@ export class CollectorSet { collectors: Map = this.collectors ) => { this.logger.debug(`Getting ready collectors`); - const readyCollectors = await this.getReadyCollectors(collectors); + const { readyCollectors, nonReadyCollectorTypes, timedOutCollectorsTypes } = + await this.getReadyCollectors(collectors); + + const collectorStats: CollectorStats = { + not_ready: { count: nonReadyCollectorTypes.length, names: nonReadyCollectorTypes }, + not_ready_timeout: { count: timedOutCollectorsTypes.length, names: timedOutCollectorsTypes }, + succeeded: { count: 0, names: [] }, + failed: { count: 0, names: [] }, + }; + const responses = await Promise.all( readyCollectors.map(async (collector) => { this.logger.debug(`Fetching data from ${collector.type} collector`); @@ -182,17 +208,23 @@ export class CollectorSet { soClient, ...(collector.extendFetchContext.kibanaRequest && { kibanaRequest }), }; - return { - type: collector.type, - result: await collector.fetch(context), - }; + const result = await collector.fetch(context); + collectorStats.succeeded.names.push(collector.type); + return { type: collector.type, result }; } catch (err) { this.logger.warn(err); this.logger.warn(`Unable to fetch data from ${collector.type} collector`); + collectorStats.failed.names.push(collector.type); } }) ); + collectorStats.succeeded.count = collectorStats.succeeded.names.length; + collectorStats.failed.count = collectorStats.failed.names.length; + + // Treat it as just another "collector" + responses.push({ type: 'usage_collector_stats', result: collectorStats }); + return responses.filter( (response): response is { type: string; result: unknown } => typeof response !== 'undefined' ); From 860a2074c848238597b8b9e54ee32c684dd2779d Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Mon, 31 Jan 2022 11:24:02 +0100 Subject: [PATCH 04/65] fix policy validation (#124040) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../agent_policy/create_package_policy_page/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx index 5d71ef6c7a703..732231e178e8d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx @@ -174,17 +174,17 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { if (updatedAgentPolicy) { setAgentPolicy(updatedAgentPolicy); if (packageInfo) { - setFormState('VALID'); + setHasAgentPolicyError(false); } } else { - setFormState('INVALID'); + setHasAgentPolicyError(true); setAgentPolicy(undefined); } // eslint-disable-next-line no-console console.debug('Agent policy updated', updatedAgentPolicy); }, - [packageInfo, setAgentPolicy, setFormState] + [packageInfo, setAgentPolicy] ); const hasErrors = validationResults ? validationHasErrors(validationResults) : false; @@ -227,6 +227,8 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const hasAgentPolicy = newPackagePolicy.policy_id && newPackagePolicy.policy_id !== ''; if (hasPackage && hasAgentPolicy && !hasValidationErrors) { setFormState('VALID'); + } else { + setFormState('INVALID'); } }, [packagePolicy, updatePackagePolicyValidation] From 3d9a0826764e4e56d5c3298d8bda6a8027a582bc Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 31 Jan 2022 12:55:18 +0200 Subject: [PATCH 05/65] [XY] Normal mode percentages computation with EC (#123941) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../utils/compute_percentage_data.test.ts | 22 +++---- .../public/utils/compute_percentage_data.ts | 61 ++++--------------- 2 files changed, 24 insertions(+), 59 deletions(-) diff --git a/src/plugins/vis_types/xy/public/utils/compute_percentage_data.test.ts b/src/plugins/vis_types/xy/public/utils/compute_percentage_data.test.ts index 835b6918f196f..0429bb5253b6f 100644 --- a/src/plugins/vis_types/xy/public/utils/compute_percentage_data.test.ts +++ b/src/plugins/vis_types/xy/public/utils/compute_percentage_data.test.ts @@ -112,7 +112,7 @@ describe('computePercentageData', () => { }, { 'col-0-4': 'ES-Air', - 'col-1-5': 0.08653846153846152, + 'col-1-5': 0.08653846153846154, 'col-2-1': 0.1346153846153846, 'col-3-1': 1, }, @@ -130,7 +130,7 @@ describe('computePercentageData', () => { }, { 'col-0-4': 'JetBeats', - 'col-1-5': 0.10576923076923078, + 'col-1-5': 0.10576923076923077, 'col-2-1': 0.6923076923076923, 'col-3-1': 0, }, @@ -142,14 +142,14 @@ describe('computePercentageData', () => { }, { 'col-0-4': 'Logstash Airways', - 'col-1-5': 0.05376344086021506, - 'col-2-1': 0.7634408602150539, + 'col-1-5': 0.053763440860215055, + 'col-2-1': 0.7634408602150538, 'col-3-1': 1, }, { 'col-0-4': 'Logstash Airways', 'col-1-5': 0.07526881720430108, - 'col-2-1': 0.10752688172043012, + 'col-2-1': 0.10752688172043011, 'col-3-1': 0, }, ]); @@ -171,7 +171,7 @@ describe('computePercentageData', () => { }, { 'col-0-4': 'ES-Air', - 'col-1-5': 0.08653846153846152, + 'col-1-5': 0.08653846153846154, 'col-2-1': 0.1346153846153846, 'col-3-1': 1, }, @@ -184,12 +184,12 @@ describe('computePercentageData', () => { { 'col-0-4': 'Kibana Airlines', 'col-1-5': 0.38095238095238093, - 'col-2-1': 0.619047619047619, + 'col-2-1': 0.6190476190476191, 'col-3-1': 1, }, { 'col-0-4': 'JetBeats', - 'col-1-5': 0.10576923076923078, + 'col-1-5': 0.10576923076923077, 'col-2-1': 0.6923076923076923, 'col-3-1': 0, }, @@ -202,13 +202,13 @@ describe('computePercentageData', () => { { 'col-0-4': 'Logstash Airways', 'col-1-5': 0.06578947368421052, - 'col-2-1': 0.9342105263157894, + 'col-2-1': 0.9342105263157895, 'col-3-1': 1, }, { 'col-0-4': 'Logstash Airways', - 'col-1-5': 0.411764705882353, - 'col-2-1': 0.5882352941176472, + 'col-1-5': 0.4117647058823529, + 'col-2-1': 0.5882352941176471, 'col-3-1': 0, }, ]); diff --git a/src/plugins/vis_types/xy/public/utils/compute_percentage_data.ts b/src/plugins/vis_types/xy/public/utils/compute_percentage_data.ts index 67e04a980d3c9..78457c2fd56cd 100644 --- a/src/plugins/vis_types/xy/public/utils/compute_percentage_data.ts +++ b/src/plugins/vis_types/xy/public/utils/compute_percentage_data.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { groupBy } from 'lodash'; -import { Accessor, AccessorFn } from '@elastic/charts'; -import { DatatableRow } from '../../../../expressions/public'; +import type { Accessor, AccessorFn } from '@elastic/charts'; +import { computeRatioByGroups } from '@elastic/charts'; +import type { DatatableRow } from '../../../../expressions/public'; export const computePercentageData = ( rows: DatatableRow[], @@ -15,52 +15,17 @@ export const computePercentageData = ( yAccessors: string[], splitChartAccessor?: string | null ) => { - // Group by xAccessor - const groupedData = groupBy(rows, function (row) { - return row[String(xAccessor)]; - }); - // In case of small multiples, I need to group by xAccessor and splitChartAccessor + // compute percentage mode data + const groupAccessors = [String(xAccessor)]; if (splitChartAccessor) { - for (const key in groupedData) { - if (Object.prototype.hasOwnProperty.call(groupedData, key)) { - const groupedBySplitData = groupBy(groupedData[key], splitChartAccessor); - for (const newGroupKey in groupedBySplitData) { - if (Object.prototype.hasOwnProperty.call(groupedBySplitData, newGroupKey)) { - groupedData[`${key}-${newGroupKey}`] = groupedBySplitData[newGroupKey]; - } - } - } - } + groupAccessors.push(splitChartAccessor); } - // sum up all the yAccessors per group - const sums: Record = {}; - for (const key in groupedData) { - if (Object.prototype.hasOwnProperty.call(groupedData, key)) { - let sum = 0; - const array = groupedData[key]; - array.forEach((row) => { - for (const yAccessor of yAccessors) { - sum += row[yAccessor]; - } - }); - sums[key] = sum; - } - } - // compute the ratio of each group - rows.forEach((row) => { - const groupValue = splitChartAccessor - ? `${row[String(xAccessor)]}-${row[splitChartAccessor]}` - : row[String(xAccessor)]; - const sum = sums[groupValue] ?? 0; - let metricsSum = 0; - for (const yAccessor of yAccessors) { - metricsSum += row[yAccessor]; - } - const computedMetric = metricsSum / sum; - for (const yAccessor of yAccessors) { - row[yAccessor] = (computedMetric / metricsSum) * row[yAccessor]; - } - }); - return rows; + return computeRatioByGroups( + rows, + groupAccessors, + yAccessors.map((accessor) => { + return [(d) => d[accessor], (d, v) => ({ ...d, [accessor]: v })]; + }) + ); }; From bc74ed2c5bfe600b56343dfe9666fbc050e06175 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 31 Jan 2022 12:17:01 +0100 Subject: [PATCH 06/65] [Lens] Stabilize drag and drop test (#124007) --- x-pack/test/functional/page_objects/lens_page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 7cbed96328727..d859a17ec9982 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -204,7 +204,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont testSubjects.getCssSelector('lnsWorkspace') ); await this.waitForLensDragDropToFinish(); - await PageObjects.header.waitUntilLoadingHasFinished(); + await this.waitForVisualization(); }, /** From a791ed6043a91227e1022343f5911584ee792214 Mon Sep 17 00:00:00 2001 From: Kuldeep M Date: Mon, 31 Jan 2022 11:20:51 +0000 Subject: [PATCH 07/65] [Workplace search] make use of panels more consistent (#122335) * fix 1786 source connection panel vertical alignment * Update x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx Co-authored-by: Constance * change panels and nested panels to better reflect EUI guidelines * change source panels into split panels * restore status icons * make available for configuration sources a list * make available for configuration sources a better list * improve configuration sources list * improve configuration sources list columns * align private source card text * fix to pass checks * move styles to CSS file and use EUI variables * fix to pass checks * switch to using flex grid to make source list * improve source list spacing * add disabled state for available sources list items * fix to pass checks * improve source list spacing * move platinum license tooltip to connect button * switch out EuiText for EuiButtonEmpty for source cards * increase flex grid gutter * change panels and nested panels to better reflect EUI guidelines * change source panels into split panels * restore status icons * make available for configuration sources a list * make available for configuration sources a better list * improve configuration sources list * improve configuration sources list columns * align private source card text * fix to pass checks * move styles to CSS file and use EUI variables * fix to pass checks * switch to using flex grid to make source list * improve source list spacing * add disabled state for available sources list items * fix to pass checks * improve source list spacing * move platinum license tooltip to connect button * switch out EuiText for EuiButtonEmpty for source cards * increase flex grid gutter * remove unused imports * fix jest tests * replace CSS value with with EUI variable Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Constance --- .../components/add_source/add_source.scss | 8 +- .../available_sources_list.test.tsx | 4 +- .../add_source/available_sources_list.tsx | 95 ++++++++++++------- .../configured_sources_list.test.tsx | 4 +- .../add_source/configured_sources_list.tsx | 86 +++++++++-------- .../components/add_source/constants.ts | 2 +- .../views/overview/organization_stats.tsx | 70 +++++++------- .../views/overview/statistic_card.tsx | 3 +- .../components/private_sources_table.tsx | 68 ++++++------- .../views/security/security.tsx | 3 +- 10 files changed, 189 insertions(+), 154 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss index fe772000f78f7..63b2d8b21ee25 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - // -------------------------------------------------- // View: Adding a Source flow // -------------------------------------------------- @@ -79,6 +78,7 @@ .euiFlexItem:first-child { align-self: flex-end; } + .euiFlexItem:last-child { align-self: flex-start; } @@ -98,3 +98,9 @@ background: transparent; } } + +@media (min-width: 768px) { + .organizational-content-source-item { + max-width: calc(33.3% - #{$euiSize}); + } +} \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx index 26a3d38f247ea..f168dfbea91ce 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiCard, EuiToolTip, EuiTitle } from '@elastic/eui'; +import { EuiToolTip, EuiTitle } from '@elastic/eui'; import { AvailableSourcesList } from './available_sources_list'; @@ -25,8 +25,8 @@ describe('AvailableSourcesList', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiCard)).toHaveLength(11); expect(wrapper.find(EuiTitle)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(11); expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx index 12dd0d2c574ec..13f0f41643e16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx @@ -10,9 +10,10 @@ import React from 'react'; import { useValues } from 'kea'; import { - EuiCard, EuiFlexGrid, + EuiFlexGroup, EuiFlexItem, + EuiHorizontalRule, EuiSpacer, EuiTitle, EuiText, @@ -21,7 +22,7 @@ import { import { i18n } from '@kbn/i18n'; import { LicensingLogic } from '../../../../../shared/licensing'; -import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { EuiButtonEmptyTo, EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; import { ADD_CUSTOM_PATH, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; @@ -42,44 +43,70 @@ export const AvailableSourcesList: React.FC = ({ sour const getSourceCard = ({ name, serviceType, addPath, accountContextOnly }: SourceDataItem) => { const disabled = !hasPlatinumLicense && accountContextOnly; + + const connectButton = () => { + if (disabled) { + return ( + + + Connect + + + ); + } else { + return ( + + Connect + + ); + } + }; + const card = ( - } - isDisabled={disabled} - icon={} - /> + <> + + + + + + {name} + + {connectButton()} + + + + ); - if (disabled) { - return ( - - {card} - - ); - } - return {card}; + return card; }; const visibleSources = ( - - {sources.map((source, i) => ( - - {getSourceCard(source)} - - ))} - + <> + + {sources.map((source, i) => ( + + + {getSourceCard(source)} + + + ))} + + ); const emptyState = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx index 2e2e04556cdb7..a1169cd582cba 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx @@ -11,8 +11,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPanel } from '@elastic/eui'; - import { ConfiguredSourcesList } from './configured_sources_list'; describe('ConfiguredSourcesList', () => { @@ -26,7 +24,7 @@ describe('ConfiguredSourcesList', () => { expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(5); expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(1); - expect(wrapper.find(EuiPanel)).toHaveLength(6); + expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(6); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index b5b22afec39b1..ac465c43643a4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -8,11 +8,12 @@ import React from 'react'; import { + EuiButtonEmpty, EuiFlexGrid, EuiFlexGroup, EuiFlexItem, - EuiPanel, EuiSpacer, + EuiSplitPanel, EuiText, EuiTitle, EuiToken, @@ -31,6 +32,7 @@ import { CONFIGURED_SOURCES_EMPTY_STATE, CONFIGURED_SOURCES_TITLE, CONFIGURED_SOURCES_EMPTY_BODY, + ADD_SOURCE_ORG_SOURCES_TITLE, } from './constants'; interface ConfiguredSourcesProps { @@ -65,50 +67,52 @@ export const ConfiguredSourcesList: React.FC = ({ ); const visibleSources = ( - + {sources.map(({ name, serviceType, addPath, connected, accountContextOnly }, i) => ( - - - - - - - - - - -

- {name} - {!connected && - !accountContextOnly && - isOrganization && - unConnectedTooltip} - {accountContextOnly && isOrganization && accountOnlyTooltip} -

-
-
-
-
- {(!isOrganization || (isOrganization && !accountContextOnly)) && ( - - - {CONFIGURED_SOURCES_CONNECT_BUTTON} - + + + + + + + + + +

+ {name} + {!connected && !accountContextOnly && isOrganization && unConnectedTooltip} + {accountContextOnly && isOrganization && accountOnlyTooltip} +

+
+
+
+ + {((!isOrganization || (isOrganization && !accountContextOnly)) && ( + + {CONFIGURED_SOURCES_CONNECT_BUTTON} + + )) || ( + + {ADD_SOURCE_ORG_SOURCES_TITLE} + )} -
-
+ +
))} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts index cbc18f6d7a19e..3ce4f930b7a38 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts @@ -226,7 +226,7 @@ export const CONFIGURED_SOURCES_LIST_ACCOUNT_ONLY_TOOLTIP = i18n.translate( export const CONFIGURED_SOURCES_CONNECT_BUTTON = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configuredSources.connectButton', { - defaultMessage: 'Connect', + defaultMessage: 'Connect another', } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx index 3528f97e16ad1..d90305fa92014 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiFlexGrid, EuiPanel } from '@elastic/eui'; +import { EuiFlexGrid } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -32,41 +32,39 @@ export const OrganizationStats: React.FC = () => { /> } > - - - - - - - - + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx index 9b134b511b34e..9d927099b13b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx @@ -25,6 +25,7 @@ export const StatisticCard: React.FC = ({ title, count = 0, title={title} titleSize="xs" display="plain" + hasBorder description={ {count} @@ -37,7 +38,7 @@ export const StatisticCard: React.FC = ({ title, count = 0, layout="horizontal" title={title} titleSize="xs" - display="plain" + display="subdued" description={ {count} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx index 94906ab9f302d..60cc08cd6c39c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx @@ -100,7 +100,7 @@ export const PrivateSourcesTable: React.FC = ({ const emptyState = ( <> - + {isRemote ? REMOTE_SOURCES_EMPTY_TABLE_TITLE : STANDARD_SOURCES_EMPTY_TABLE_TITLE} @@ -115,6 +115,38 @@ export const PrivateSourcesTable: React.FC = ({ ); + const sourcesTable = ( + <> + + + + + {SOURCE} + + + + {contentSources.map((source, i) => ( + + {source.name} + + updateSource(source.id, e.target.checked)} + showLabel={false} + label={`${source.name} Toggle`} + data-test-subj={`${sourceType}SourceToggle`} + compressed + /> + + + ))} + + + + + ); + const sectionHeading = ( @@ -137,49 +169,19 @@ export const PrivateSourcesTable: React.FC = ({ {isRemote ? REMOTE_SOURCES_TABLE_DESCRIPTION : STANDARD_SOURCES_TABLE_DESCRIPTION} {!hasSources && emptyState} + {hasSources && sourcesTable} ); - const sourcesTable = ( - <> - - - - {SOURCE} - - - - {contentSources.map((source, i) => ( - - {source.name} - - updateSource(source.id, e.target.checked)} - showLabel={false} - label={`${source.name} Toggle`} - data-test-subj={`${sourceType}SourceToggle`} - compressed - /> - - - ))} - - - - ); - return ( {sectionHeading} - {hasSources && sourcesTable} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx index 997d79f67cb13..be397b4acbc36 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx @@ -92,7 +92,6 @@ export const Security: React.FC = () => { { hasUnsavedChanges={unsavedChanges} messageText={SECURITY_UNSAVED_CHANGES_MESSAGE} /> - + {allSourcesToggle} {!hasPlatinumLicense && platinumLicenseCallout} {sourceTables} From bc6e30d7406f7d77dd2ddbf3f8fef5b41377ab30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 31 Jan 2022 12:47:20 +0100 Subject: [PATCH 08/65] [Telemetry] Only send from active window (#123909) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/telemetry/public/plugin.ts | 4 +++ .../public/services/telemetry_sender.test.ts | 29 +++++++++++++-- .../public/services/telemetry_sender.ts | 35 ++++++++++++++++--- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index 73dc07d7a4fb9..3072ff67703d7 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -186,6 +186,10 @@ export class TelemetryPlugin implements Plugin { }); describe('shouldSendReport', () => { + let hasFocus: jest.SpyInstance; + + beforeEach(() => { + hasFocus = jest.spyOn(document, 'hasFocus'); + hasFocus.mockReturnValue(true); // Return true by default for all tests; + }); + + afterEach(() => { + hasFocus.mockRestore(); + }); + + it('returns false if the page is not visible', async () => { + hasFocus.mockReturnValue(false); + const telemetryService = mockTelemetryService(); + telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); + telemetryService.fetchLastReported = jest.fn().mockResolvedValue(Date.now()); + const telemetrySender = new TelemetrySender(telemetryService); + const shouldSendReport = await telemetrySender['shouldSendReport'](); + expect(shouldSendReport).toBe(false); + expect(telemetryService.getIsOptedIn).toBeCalledTimes(0); + expect(telemetryService.fetchLastReported).toBeCalledTimes(0); + }); + it('returns false whenever optIn is false', async () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false); @@ -372,9 +395,11 @@ describe('TelemetrySender', () => { it('calls sendIfDue every 60000 ms', () => { const telemetryService = mockTelemetryService(); const telemetrySender = new TelemetrySender(telemetryService); + telemetrySender['sendIfDue'] = jest.fn().mockResolvedValue(void 0); telemetrySender.startChecking(); - expect(setInterval).toBeCalledTimes(1); - expect(setInterval).toBeCalledWith(telemetrySender['sendIfDue'], 60000); + expect(telemetrySender['sendIfDue']).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(60000); + expect(telemetrySender['sendIfDue']).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/plugins/telemetry/public/services/telemetry_sender.ts b/src/plugins/telemetry/public/services/telemetry_sender.ts index d0eb9142e724a..8fa2a015ef119 100644 --- a/src/plugins/telemetry/public/services/telemetry_sender.ts +++ b/src/plugins/telemetry/public/services/telemetry_sender.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +import type { Subscription } from 'rxjs'; +import { fromEvent, interval, merge } from 'rxjs'; +import { exhaustMap } from 'rxjs/operators'; import { LOCALSTORAGE_KEY, PAYLOAD_CONTENT_ENCODING } from '../../common/constants'; import { TelemetryService } from './telemetry_service'; import { Storage } from '../../../kibana_utils/public'; @@ -16,7 +19,7 @@ export class TelemetrySender { private readonly telemetryService: TelemetryService; private lastReported?: number; private readonly storage: Storage; - private intervalId: number = 0; // setInterval returns a positive integer, 0 means no interval is set + private sendIfDue$?: Subscription; private retryCount: number = 0; static getRetryDelay(retryCount: number) { @@ -62,11 +65,21 @@ export class TelemetrySender { }; /** - * Using configuration and the lastReported dates, it decides whether a new telemetry report should be sent. + * Returns `true` when the page is visible and active in the browser. + */ + private isActiveWindow = () => { + // Using `document.hasFocus()` instead of `document.visibilityState` because the latter may return "visible" + // if 2 windows are open side-by-side because they are "technically" visible. + return document.hasFocus(); + }; + + /** + * Using configuration, page visibility state and the lastReported dates, + * it decides whether a new telemetry report should be sent. * @returns `true` if a new report should be sent. `false` otherwise. */ private shouldSendReport = async (): Promise => { - if (this.telemetryService.canSendTelemetry()) { + if (this.isActiveWindow() && this.telemetryService.canSendTelemetry()) { return await this.isReportDue(); } @@ -122,8 +135,20 @@ export class TelemetrySender { }; public startChecking = () => { - if (this.intervalId === 0) { - this.intervalId = window.setInterval(this.sendIfDue, 60000); + if (!this.sendIfDue$) { + // Trigger sendIfDue... + this.sendIfDue$ = merge( + // ... periodically + interval(60000), + // ... when it regains `focus` + fromEvent(window, 'focus') // Using `window` instead of `document` because Chrome only emits on the first one. + ) + .pipe(exhaustMap(this.sendIfDue)) + .subscribe(); } }; + + public stop = () => { + this.sendIfDue$?.unsubscribe(); + }; } From ecba4787f550e7dbce2f642ceb71b823de7c9d28 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Mon, 31 Jan 2022 12:49:59 +0100 Subject: [PATCH 09/65] [Cases] Fixes toast message showing for non-synced cases (#124026) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/recent_cases/index.test.tsx | 2 +- .../cases/public/containers/api.test.tsx | 1 + .../plugins/cases/public/containers/mock.ts | 34 +++++++++++++++ .../public/containers/use_get_cases.test.tsx | 43 ++++++++++++++++++- .../cases/public/containers/use_get_cases.tsx | 2 +- 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx index 0a1e0447511db..4f99f23b9b208 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx @@ -62,7 +62,7 @@ describe('RecentCases', () => { ); - expect(getAllByTestId('case-details-link')).toHaveLength(5); + expect(getAllByTestId('case-details-link')).toHaveLength(7); }); it('is good at rendering max cases', () => { diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index c83f5601da64b..e656544084d09 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -229,6 +229,7 @@ describe('Case Configuration API', () => { }); test('should return correct response', async () => { + fetchMock.mockResolvedValue(allCasesSnake); const resp = await getCases({ filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] }, queryParams: DEFAULT_QUERY_PARAMS, diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index d5e749c46a900..c0fb9fd9f0138 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -36,6 +36,8 @@ import { covertToSnakeCase } from './utils'; export { connectorsMock } from './configure/mock'; export const basicCaseId = 'basic-case-id'; +export const caseWithAlertsId = 'case-with-alerts-id'; +export const caseWithAlertsSyncOffId = 'case-with-alerts-syncoff-id'; export const basicSubCaseId = 'basic-sub-case-id'; const basicCommentId = 'basic-comment-id'; const basicCreatedAt = '2020-02-19T23:06:33.798Z'; @@ -169,6 +171,20 @@ export const basicCase: Case = { subCaseIds: [], }; +export const caseWithAlerts = { + ...basicCase, + totalAlerts: 2, + id: caseWithAlertsId, +}; +export const caseWithAlertsSyncOff = { + ...basicCase, + totalAlerts: 2, + settings: { + syncAlerts: false, + }, + id: caseWithAlertsSyncOffId, +}; + export const basicResolvedCase: ResolvedCase = { case: basicCase, outcome: 'aliasMatch', @@ -314,6 +330,8 @@ export const cases: Case[] = [ { ...pushedCase, updatedAt: laterTime, id: '2', totalComment: 0, comments: [] }, { ...basicCase, id: '3', totalComment: 0, comments: [] }, { ...basicCase, id: '4', totalComment: 0, comments: [] }, + caseWithAlerts, + caseWithAlertsSyncOff, ]; export const allCases: AllCases = { @@ -378,6 +396,20 @@ export const basicCaseSnake: CaseResponse = { owner: SECURITY_SOLUTION_OWNER, } as CaseResponse; +export const caseWithAlertsSnake = { + ...basicCaseSnake, + totalAlerts: 2, + id: caseWithAlertsId, +}; +export const caseWithAlertsSyncOffSnake = { + ...basicCaseSnake, + totalAlerts: 2, + settings: { + syncAlerts: false, + }, + id: caseWithAlertsSyncOffId, +}; + export const casesStatusSnake: CasesStatusResponse = { count_closed_cases: 130, count_in_progress_cases: 40, @@ -423,6 +455,8 @@ export const casesSnake: CasesResponse = [ { ...pushedCaseSnake, updated_at: laterTime, id: '2', totalComment: 0, comments: [] }, { ...basicCaseSnake, id: '3', totalComment: 0, comments: [] }, { ...basicCaseSnake, id: '4', totalComment: 0, comments: [] }, + caseWithAlertsSnake, + caseWithAlertsSyncOffSnake, ]; export const allCasesSnake: CasesFindResponse = { diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index 23b57004ca4d7..dee4d424c84de 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -17,7 +17,7 @@ import { UseGetCases, } from './use_get_cases'; import { UpdateKey } from './types'; -import { allCases, basicCase } from './mock'; +import { allCases, basicCase, caseWithAlerts, caseWithAlertsSyncOff } from './mock'; import * as api from './api'; import { TestProviders } from '../common/mock'; import { useToasts } from '../common/lib/kibana'; @@ -121,6 +121,47 @@ describe('useGetCases', () => { }); }); + it('shows a success toast notifying of synced alerts when sync is on', async () => { + await act(async () => { + const updateCase = { + updateKey: 'status' as UpdateKey, + updateValue: 'open', + caseId: caseWithAlerts.id, + refetchCasesStatus: jest.fn(), + version: '99999', + }; + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); + result.current.dispatchUpdateCaseProperty(updateCase); + }); + expect(addSuccess).toHaveBeenCalledWith({ + text: 'Updated the statuses of attached alerts.', + title: 'Updated "Another horrible breach!!"', + }); + }); + + it('shows a success toast without notifying of synced alerts when sync is off', async () => { + await act(async () => { + const updateCase = { + updateKey: 'status' as UpdateKey, + updateValue: 'open', + caseId: caseWithAlertsSyncOff.id, + refetchCasesStatus: jest.fn(), + version: '99999', + }; + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); + result.current.dispatchUpdateCaseProperty(updateCase); + }); + expect(addSuccess).toHaveBeenCalledWith({ + title: 'Updated "Another horrible breach!!"', + }); + }); + it('refetch cases', async () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); await act(async () => { diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index de0f0f514da1b..3b144620b6352 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -235,7 +235,7 @@ export const useGetCases = ( toasts.addSuccess({ title: i18n.UPDATED_CASE(caseData.title), text: - updateKey === 'status' && caseData.totalAlerts > 0 + updateKey === 'status' && caseData.totalAlerts > 0 && caseData.settings.syncAlerts ? i18n.STATUS_CHANGED_TOASTER_TEXT : undefined, }); From 78643f495c925154bb20839b3d12f241cb4ffe66 Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Mon, 31 Jan 2022 11:54:34 +0000 Subject: [PATCH 10/65] APM UI changes for serverless services / AWS lambda (#122775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adapted service UI for AWS lambda / serverless services * Add unit tests for isServerlessAgent function * Add story for cold start badge * Add unit tests for service icons and icon details * Add aws_lambda checks to isMetricsTabHidden and isJVMsTabHidden unit tests * Add API test for coldstart_rate chart * Change service icon API tests to use synthrace and test for serverless property * Change service details API tests to use synthrace and add test for serverless * Add e2e tests for cold start rate chart * Add cold start badge to transaction flyout * Add beta badge to cloud details in a lambda context * Add support for multiple lambda functions in a single service Co-authored-by: Alexander Wert Co-authored-by: Casper Hübertz --- .../src/lib/apm/apm_fields.ts | 16 ++ .../lib/apm/utils/get_transaction_metrics.ts | 14 ++ .../elasticsearch_fieldnames.test.ts.snap | 24 ++ x-pack/plugins/apm/common/agent_name.test.ts | 33 ++- x-pack/plugins/apm/common/agent_name.ts | 4 + .../apm/common/elasticsearch_fieldnames.ts | 5 + .../aws_lambda/aws_lamba.spec.ts | 53 +++++ .../aws_lambda/generate_data.ts | 41 ++++ .../components/app/service_overview/index.tsx | 44 +++- .../app/transaction_details/index.tsx | 11 +- .../badge/cold_start_badge.stories.tsx | 18 ++ .../waterfall/badge/cold_start_badge.tsx | 20 ++ .../{ => badge}/sync_badge.stories.tsx | 2 +- .../waterfall/{ => badge}/sync_badge.test.tsx | 0 .../waterfall/{ => badge}/sync_badge.tsx | 2 +- .../waterfall/span_flyout/index.tsx | 2 +- .../waterfall/transaction_flyout/index.tsx | 1 + .../waterfall/waterfall_item.tsx | 6 +- .../waterfall_container.stories.data.ts | 3 + .../waterfall_container.stories.tsx | 11 +- .../app/transaction_overview/index.tsx | 10 +- .../apm_service_template/index.test.tsx | 2 + .../templates/apm_service_template/index.tsx | 9 +- .../charts/transaction_charts/index.tsx | 32 ++- .../index.tsx | 184 ++++++++++++++++ .../shared/service_icons/cloud_details.tsx | 72 +++++- .../shared/service_icons/index.test.tsx | 116 ++++++++++ .../components/shared/service_icons/index.tsx | 21 +- .../service_icons/serverless_details.tsx | 73 ++++++ .../shared/summary/transaction_summary.tsx | 10 +- .../lib/helpers/transaction_coldstart_rate.ts | 67 ++++++ .../transaction_groups/get_coldstart_rate.ts | 181 +++++++++++++++ .../services/get_service_metadata_details.ts | 58 +++++ .../services/get_service_metadata_icons.ts | 17 +- .../apm/server/routes/transactions/route.ts | 155 +++++++++++++ .../typings/es_schemas/raw/fields/cloud.ts | 3 + .../apm/typings/es_schemas/raw/fields/faas.ts | 16 ++ .../typings/es_schemas/raw/transaction_raw.ts | 2 + .../tests/cold_start/cold_start.spec.ts | 205 +++++++++++++++++ .../cold_start_by_transaction_name.spec.ts | 207 ++++++++++++++++++ .../generate_data.ts | 64 ++++++ .../tests/cold_start/generate_data.ts | 70 ++++++ .../tests/services/service_details.spec.ts | 135 ------------ .../services/service_details/generate_data.ts | 130 +++++++++++ .../service_details/service_details.spec.ts | 128 +++++++++++ .../tests/services/service_icons.spec.ts | 77 ------- .../services/service_icons/generate_data.ts | 56 +++++ .../service_icons/service_icons.spec.ts | 77 +++++++ 48 files changed, 2240 insertions(+), 247 deletions(-) create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/aws_lamba.spec.ts create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts create mode 100644 x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.stories.tsx create mode 100644 x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.tsx rename x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/{ => badge}/sync_badge.stories.tsx (94%) rename x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/{ => badge}/sync_badge.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/{ => badge}/sync_badge.tsx (94%) create mode 100644 x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/service_icons/serverless_details.tsx create mode 100644 x-pack/plugins/apm/server/lib/helpers/transaction_coldstart_rate.ts create mode 100644 x-pack/plugins/apm/server/lib/transaction_groups/get_coldstart_rate.ts create mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/faas.ts create mode 100644 x-pack/test/apm_api_integration/tests/cold_start/cold_start.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/cold_start_by_transaction_name.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/generate_data.ts create mode 100644 x-pack/test/apm_api_integration/tests/cold_start/generate_data.ts delete mode 100644 x-pack/test/apm_api_integration/tests/services/service_details.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/services/service_details/generate_data.ts create mode 100644 x-pack/test/apm_api_integration/tests/services/service_details/service_details.spec.ts delete mode 100644 x-pack/test/apm_api_integration/tests/services/service_icons.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/services/service_icons/generate_data.ts create mode 100644 x-pack/test/apm_api_integration/tests/services/service_icons/service_icons.spec.ts diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts index e0a48fdcf2b89..4afebf0352a6a 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts @@ -63,8 +63,12 @@ export type ApmFields = Fields & }; 'transaction.sampled': true; 'service.name': string; + 'service.version': string; 'service.environment': string; 'service.node.name': string; + 'service.runtime.name': string; + 'service.runtime.version': string; + 'service.framework.name': string; 'span.id': string; 'span.name': string; 'span.type': string; @@ -77,5 +81,17 @@ export type ApmFields = Fields & 'span.destination.service.response_time.count': number; 'span.self_time.count': number; 'span.self_time.sum.us': number; + 'cloud.provider': string; + 'cloud.project.name': string; + 'cloud.service.name': string; + 'cloud.availability_zone': string; + 'cloud.machine.type': string; + 'cloud.region': string; + 'host.os.platform': string; + 'faas.id': string; + 'faas.coldstart': boolean; + 'faas.execution': string; + 'faas.trigger.type': string; + 'faas.trigger.request_id': string; }> & ApmApplicationMetricFields; diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts b/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts index 8545ae65d8aa0..baa9f57a19a46 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts @@ -51,6 +51,20 @@ export function getTransactionMetrics(events: ApmFields[]) { 'host.name', 'container.id', 'kubernetes.pod.name', + 'cloud.account.id', + 'cloud.account.name', + 'cloud.machine.type', + 'cloud.project.id', + 'cloud.project.name', + 'cloud.service.name', + 'service.language.name', + 'service.language.version', + 'service.runtime.name', + 'service.runtime.version', + 'host.os.platform', + 'faas.id', + 'faas.coldstart', + 'faas.trigger.type', ]); return metricsets.map((metricset) => { diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 5dd3588674179..9b255a87df39e 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -37,6 +37,8 @@ exports[`Error CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Error CLOUD_REGION 1`] = `"europe-west1"`; +exports[`Error CLOUD_SERVICE_NAME 1`] = `undefined`; + exports[`Error CLS_FIELD 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -63,6 +65,12 @@ exports[`Error ERROR_PAGE_URL 1`] = `undefined`; exports[`Error EVENT_OUTCOME 1`] = `undefined`; +exports[`Error FAAS_COLDSTART 1`] = `undefined`; + +exports[`Error FAAS_ID 1`] = `undefined`; + +exports[`Error FAAS_TRIGGER_TYPE 1`] = `undefined`; + exports[`Error FCP_FIELD 1`] = `undefined`; exports[`Error FID_FIELD 1`] = `undefined`; @@ -282,6 +290,8 @@ exports[`Span CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Span CLOUD_REGION 1`] = `"europe-west1"`; +exports[`Span CLOUD_SERVICE_NAME 1`] = `undefined`; + exports[`Span CLS_FIELD 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -308,6 +318,12 @@ exports[`Span ERROR_PAGE_URL 1`] = `undefined`; exports[`Span EVENT_OUTCOME 1`] = `"unknown"`; +exports[`Span FAAS_COLDSTART 1`] = `undefined`; + +exports[`Span FAAS_ID 1`] = `undefined`; + +exports[`Span FAAS_TRIGGER_TYPE 1`] = `undefined`; + exports[`Span FCP_FIELD 1`] = `undefined`; exports[`Span FID_FIELD 1`] = `undefined`; @@ -519,6 +535,8 @@ exports[`Transaction CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Transaction CLOUD_REGION 1`] = `"europe-west1"`; +exports[`Transaction CLOUD_SERVICE_NAME 1`] = `undefined`; + exports[`Transaction CLS_FIELD 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -545,6 +563,12 @@ exports[`Transaction ERROR_PAGE_URL 1`] = `undefined`; exports[`Transaction EVENT_OUTCOME 1`] = `"unknown"`; +exports[`Transaction FAAS_COLDSTART 1`] = `undefined`; + +exports[`Transaction FAAS_ID 1`] = `undefined`; + +exports[`Transaction FAAS_TRIGGER_TYPE 1`] = `undefined`; + exports[`Transaction FCP_FIELD 1`] = `undefined`; exports[`Transaction FID_FIELD 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/agent_name.test.ts b/x-pack/plugins/apm/common/agent_name.test.ts index 162a5716d6c7b..e48fa502d33d1 100644 --- a/x-pack/plugins/apm/common/agent_name.test.ts +++ b/x-pack/plugins/apm/common/agent_name.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { isJavaAgentName, isRumAgentName, isIosAgentName } from './agent_name'; +import { + isJavaAgentName, + isRumAgentName, + isIosAgentName, + isServerlessAgent, +} from './agent_name'; describe('agent name helpers', () => { describe('isJavaAgentName', () => { @@ -79,4 +84,30 @@ describe('agent name helpers', () => { }); }); }); + + describe('isServerlessAgent', () => { + describe('when the runtime name is AWS_LAMBDA', () => { + it('returns true', () => { + expect(isServerlessAgent('AWS_LAMBDA')).toEqual(true); + }); + }); + + describe('when the runtime name is aws_lambda', () => { + it('returns true', () => { + expect(isServerlessAgent('aws_lambda')).toEqual(true); + }); + }); + + describe('when the runtime name is aws_lambda_test', () => { + it('returns true', () => { + expect(isServerlessAgent('aws_lambda_test')).toEqual(true); + }); + }); + + describe('when the runtime name is something else', () => { + it('returns false', () => { + expect(isServerlessAgent('not_aws_lambda')).toEqual(false); + }); + }); + }); }); diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index b41ae949d5867..e8947d550a8fc 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -90,3 +90,7 @@ export function isIosAgentName(agentName?: string) { export function isJRubyAgent(agentName?: string, runtimeName?: string) { return agentName === 'ruby' && runtimeName?.toLowerCase() === 'jruby'; } + +export function isServerlessAgent(runtimeName?: string) { + return runtimeName?.toLowerCase().startsWith('aws_lambda'); +} diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 5c7c953d8d900..f0ea5b6cb116e 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -13,6 +13,7 @@ export const CLOUD_MACHINE_TYPE = 'cloud.machine.type'; export const CLOUD_ACCOUNT_ID = 'cloud.account.id'; export const CLOUD_INSTANCE_ID = 'cloud.instance.id'; export const CLOUD_INSTANCE_NAME = 'cloud.instance.name'; +export const CLOUD_SERVICE_NAME = 'cloud.service.name'; export const SERVICE = 'service'; export const SERVICE_NAME = 'service.name'; @@ -152,3 +153,7 @@ export const PROFILE_ALLOC_OBJECTS = 'profile.alloc_objects.count'; export const PROFILE_ALLOC_SPACE = 'profile.alloc_space.bytes'; export const PROFILE_INUSE_OBJECTS = 'profile.inuse_objects.count'; export const PROFILE_INUSE_SPACE = 'profile.inuse_space.bytes'; + +export const FAAS_ID = 'faas.id'; +export const FAAS_COLDSTART = 'faas.coldstart'; +export const FAAS_TRIGGER_TYPE = 'faas.trigger.type'; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/aws_lamba.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/aws_lamba.spec.ts new file mode 100644 index 0000000000000..518cfefde2fb1 --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/aws_lamba.spec.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 url from 'url'; +import { synthtrace } from '../../../../../synthtrace'; +import { generateData } from './generate_data'; + +const start = '2021-10-10T00:00:00.000Z'; +const end = '2021-10-10T00:15:00.000Z'; + +const serviceOverviewHref = url.format({ + pathname: '/app/apm/services/synth-python/overview', + query: { rangeFrom: start, rangeTo: end }, +}); + +const apiToIntercept = { + endpoint: + '/internal/apm/services/synth-python/transactions/charts/coldstart_rate?*', + name: 'coldStartRequest', +}; + +describe('Service overview - aws lambda', () => { + before(async () => { + await synthtrace.index( + generateData({ + start: new Date(start).getTime(), + end: new Date(end).getTime(), + }) + ); + }); + + after(async () => { + await synthtrace.clean(); + }); + + beforeEach(() => { + cy.loginAsReadOnlyUser(); + }); + + it('displays a cold start rate chart and not a transaction breakdown chart', () => { + const { endpoint, name } = apiToIntercept; + + cy.intercept('GET', endpoint).as(name); + cy.visit(serviceOverviewHref); + cy.wait(`@${name}`); + + cy.contains('Cold start rate'); + cy.contains('Time spent by span type').should('not.exist'); + }); +}); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts new file mode 100644 index 0000000000000..2dba10e8e517e --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts @@ -0,0 +1,41 @@ +/* + * 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 { apm, timerange } from '@elastic/apm-synthtrace'; + +const dataConfig = { + serviceName: 'synth-python', + rate: 10, + transaction: { + name: 'GET /apple 🍎', + duration: 1000, + }, +}; + +export function generateData({ start, end }: { start: number; end: number }) { + const { rate, transaction, serviceName } = dataConfig; + const instance = apm + .service(serviceName, 'production', 'python') + .instance('instance-a'); + + const traceEvents = timerange(start, end) + .interval('1m') + .rate(rate) + .flatMap((timestamp) => [ + ...instance + .transaction(transaction.name) + .defaults({ + 'service.runtime.name': 'AWS_Lambda_python3.8', + 'faas.coldstart': true, + }) + .timestamp(timestamp) + .duration(transaction.duration) + .success() + .serialize(), + ]); + + return traceEvents; +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 2c30027770f43..3c2db54088770 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -9,13 +9,18 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useHistory } from 'react-router-dom'; -import { isRumAgentName, isIosAgentName } from '../../../../common/agent_name'; +import { + isRumAgentName, + isIosAgentName, + isServerlessAgent, +} from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; +import { TransactionColdstartRateChart } from '../../shared/charts/transaction_coldstart_rate_chart'; import { FailedTransactionRateChart } from '../../shared/charts/failed_transaction_rate_chart'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; @@ -35,8 +40,13 @@ import { replace } from '../../shared/links/url_helpers'; export const chartHeight = 288; export function ServiceOverview() { - const { agentName, serviceName, transactionType, fallbackToTransactions } = - useApmServiceContext(); + const { + agentName, + serviceName, + transactionType, + fallbackToTransactions, + runtimeName, + } = useApmServiceContext(); const { query, query: { @@ -69,7 +79,7 @@ export function ServiceOverview() { const rowDirection = isSingleColumn ? 'column' : 'row'; const isRumAgent = isRumAgentName(agentName); const isIosAgent = isIosAgentName(agentName); - + const isServerless = isServerlessAgent(runtimeName); const router = useApmRouter(); const dependenciesLink = router.link('/services/{serviceName}/dependencies', { path: { @@ -152,13 +162,23 @@ export function ServiceOverview() { gutterSize="s" responsive={false} > - - - + {isServerless ? ( + + + + ) : ( + + + + )} {!isRumAgent && ( @@ -180,7 +200,7 @@ export function ServiceOverview() { )} - {!isRumAgent && !isIosAgent && ( + {!isRumAgent && !isIosAgent && !isServerless && ( {fallbackToTransactions && } @@ -66,6 +72,9 @@ export function TransactionDetails() { start={start} end={end} transactionName={transactionName} + isServerlessContext={isServerless} + comparisonEnabled={comparisonEnabled} + comparisonType={comparisonType} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.stories.tsx new file mode 100644 index 0000000000000..50af7cc42435b --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.stories.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 from 'react'; +import { ColdStartBadge } from './cold_start_badge'; + +export default { + title: 'app/TransactionDetails/Waterfall/Badge/ColdStartBadge', + component: ColdStartBadge, +}; + +export function Example() { + return ; +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.tsx new file mode 100644 index 0000000000000..dfe38aab5021d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.tsx @@ -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 { EuiBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export function ColdStartBadge() { + return ( + + {i18n.translate('xpack.apm.transactionDetails.coldstartBadge', { + defaultMessage: 'cold start', + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.stories.tsx similarity index 94% rename from x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.stories.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.stories.tsx index dea05961c4cef..7209203b54cc0 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.stories.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { SyncBadge, SyncBadgeProps } from './sync_badge'; export default { - title: 'app/TransactionDetails/SyncBadge', + title: 'app/TransactionDetails/Waterfall/Badge/SyncBadge', component: SyncBadge, argTypes: { sync: { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.test.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.tsx similarity index 94% rename from x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.tsx index a51d710bf3961..85571b9065c4d 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.tsx @@ -8,7 +8,7 @@ import { EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { AgentName } from '../../../../../../../typings/es_schemas/ui/fields/agent'; +import { AgentName } from '../../../../../../../../typings/es_schemas/ui/fields/agent'; export interface SyncBadgeProps { /** diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx index 0087b0f9d1fac..0f7a6a295601b 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx @@ -34,7 +34,7 @@ import { DurationSummaryItem } from '../../../../../../shared/summary/duration_s import { HttpInfoSummaryItem } from '../../../../../../shared/summary/http_info_summary_item'; import { TimestampTooltip } from '../../../../../../shared/timestamp_tooltip'; import { ResponsiveFlyout } from '../responsive_flyout'; -import { SyncBadge } from '../sync_badge'; +import { SyncBadge } from '../badge/sync_badge'; import { SpanDatabase } from './span_db'; import { StickySpanProperties } from './sticky_span_properties'; import { FailureBadge } from '../failure_badge'; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx index 5f1e0cacd8483..43a7ebfa2f97f 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx @@ -87,6 +87,7 @@ export function TransactionFlyout({ transaction={transactionDoc} totalDuration={rootTransactionDuration} errorCount={errorCount} + coldStartBadge={transactionDoc.faas?.coldstart} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx index d8942cab36f77..51e9cbeaba6f7 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx @@ -18,7 +18,8 @@ import { import { asDuration } from '../../../../../../../common/utils/formatters'; import { Margins } from '../../../../../shared/charts/timeline'; import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip'; -import { SyncBadge } from './sync_badge'; +import { SyncBadge } from './badge/sync_badge'; +import { ColdStartBadge } from './badge/cold_start_badge'; import { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers'; import { FailureBadge } from './failure_badge'; import { useApmRouter } from '../../../../../../hooks/use_apm_router'; @@ -200,6 +201,8 @@ export function WaterfallItem({ const isCompositeSpan = item.docType === 'span' && item.doc.span.composite; const itemBarStyle = getItemBarStyle(item, color, width, left); + const isServerlessColdstart = + item.docType === 'transaction' && item.doc.faas?.coldstart; return ( )} + {isServerlessColdstart && } ); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts index f3331fba0ca23..d4af0e92c9054 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts @@ -226,6 +226,9 @@ export const simpleTrace = { timestamp: { us: 1584975868787052, }, + faas: { + coldstart: true, + }, }, { parent: { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx index 312412a8cf827..db82e9e360207 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx @@ -19,9 +19,18 @@ import { traceWithErrors, urlParams as testUrlParams, } from './waterfall_container.stories.data'; +import type { ApmPluginContextValue } from '../../../../../context/apm_plugin/apm_plugin_context'; type Args = ComponentProps; +const apmPluginContextMock = { + core: { + http: { + basePath: { prepend: () => {} }, + }, + }, +} as unknown as ApmPluginContextValue; + const stories: Meta = { title: 'app/TransactionDetails/waterfall', component: WaterfallContainer, @@ -32,7 +41,7 @@ const stories: Meta = { '/services/{serviceName}/transactions/view?rangeFrom=now-15m&rangeTo=now&transactionName=testTransactionName', ]} > - + diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 39d522ca088fc..68315fc3b2b02 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -15,6 +15,7 @@ import { AggregatedTransactionsBadge } from '../../shared/aggregated_transaction import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { replace } from '../../shared/links/url_helpers'; import { TransactionsTable } from '../../shared/transactions_table'; +import { isServerlessAgent } from '../../../../common/agent_name'; export function TransactionOverview() { const { @@ -24,12 +25,14 @@ export function TransactionOverview() { rangeFrom, rangeTo, transactionType: transactionTypeFromUrl, + comparisonEnabled, + comparisonType, }, } = useApmParams('/services/{serviceName}/transactions'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { transactionType, serviceName, fallbackToTransactions } = + const { transactionType, serviceName, fallbackToTransactions, runtimeName } = useApmServiceContext(); const history = useHistory(); @@ -45,6 +48,8 @@ export function TransactionOverview() { return null; } + const isServerless = isServerlessAgent(runtimeName); + return ( <> {fallbackToTransactions && ( @@ -62,6 +67,9 @@ export function TransactionOverview() { environment={environment} start={start} end={end} + isServerlessContext={isServerless} + comparisonEnabled={comparisonEnabled} + comparisonType={comparisonType} /> diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx index 5a481b2d6f10c..03a50d6082583 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx @@ -19,6 +19,7 @@ describe('APM service template', () => { { agentName: 'ios/swift' }, { agentName: 'opentelemetry/swift' }, { agentName: 'ruby', runtimeName: 'jruby' }, + { runtimeName: 'aws_lambda' }, ].map((input) => { it(`when input ${JSON.stringify(input)}`, () => { expect(isMetricsTabHidden(input)).toBeTruthy(); @@ -52,6 +53,7 @@ describe('APM service template', () => { { agentName: 'nodejs' }, { agentName: 'php' }, { agentName: 'python' }, + { runtimeName: 'aws_lambda' }, ].map((input) => { it(`when input ${JSON.stringify(input)}`, () => { expect(isJVMsTabHidden(input)).toBeTruthy(); diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index 93c222164f026..20f907e03fc37 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -21,6 +21,7 @@ import { isJavaAgentName, isJRubyAgent, isRumAgentName, + isServerlessAgent, } from '../../../../../common/agent_name'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; @@ -144,7 +145,8 @@ export function isMetricsTabHidden({ isRumAgentName(agentName) || isJavaAgentName(agentName) || isIosAgentName(agentName) || - isJRubyAgent(agentName, runtimeName) + isJRubyAgent(agentName, runtimeName) || + isServerlessAgent(runtimeName) ); } @@ -155,7 +157,10 @@ export function isJVMsTabHidden({ agentName?: string; runtimeName?: string; }) { - return !(isJavaAgentName(agentName) || isJRubyAgent(agentName, runtimeName)); + return ( + !(isJavaAgentName(agentName) || isJRubyAgent(agentName, runtimeName)) || + isServerlessAgent(runtimeName) + ); } function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index ff1eec200a0c2..a2bfad0175a5f 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -12,7 +12,9 @@ import { ChartPointerEventContextProvider } from '../../../../context/chart_poin import { ServiceOverviewThroughputChart } from '../../../app/service_overview/service_overview_throughput_chart'; import { LatencyChart } from '../latency_chart'; import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; +import { TransactionColdstartRateChart } from '../transaction_coldstart_rate_chart'; import { FailedTransactionRateChart } from '../failed_transaction_rate_chart'; +import { TimeRangeComparisonType } from '../../../../../common/runtime_types/comparison_type_rt'; export function TransactionCharts({ kuery, @@ -20,12 +22,18 @@ export function TransactionCharts({ start, end, transactionName, + isServerlessContext, + comparisonEnabled, + comparisonType, }: { kuery: string; environment: string; start: string; end: string; transactionName?: string; + isServerlessContext?: boolean; + comparisonEnabled?: boolean; + comparisonType?: TimeRangeComparisonType; }) { return ( <> @@ -56,12 +64,24 @@ export function TransactionCharts({ - - - + {isServerlessContext ? ( + + + + ) : ( + + + + )}
diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx new file mode 100644 index 0000000000000..2b99562b67172 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx @@ -0,0 +1,184 @@ +/* + * 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 { + EuiPanel, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; +import { asPercent } from '../../../../../common/utils/formatters'; +import { useFetcher } from '../../../../hooks/use_fetcher'; +import { useTheme } from '../../../../hooks/use_theme'; +import { TimeseriesChart } from '../timeseries_chart'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../../time_comparison/get_time_range_comparison'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { TimeRangeComparisonType } from '../../../../../common/runtime_types/comparison_type_rt'; + +function yLabelFormat(y?: number | null) { + return asPercent(y || 0, 1); +} + +interface Props { + height?: number; + showAnnotations?: boolean; + kuery: string; + environment: string; + transactionName?: string; + comparisonEnabled?: boolean; + comparisonType?: TimeRangeComparisonType; +} + +type ColdstartRate = + APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate'>; + +const INITIAL_STATE: ColdstartRate = { + currentPeriod: { + transactionColdstartRate: [], + average: null, + }, + previousPeriod: { + transactionColdstartRate: [], + average: null, + }, +}; + +export function TransactionColdstartRateChart({ + height, + showAnnotations = true, + environment, + kuery, + transactionName, + comparisonEnabled, + comparisonType, +}: Props) { + const theme = useTheme(); + + const { + query: { rangeFrom, rangeTo }, + } = useApmParams('/services/{serviceName}'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const { serviceName, transactionType } = useApmServiceContext(); + const comparisonChartThem = getComparisonChartTheme(); + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); + + const endpoint = transactionName + ? ('GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate_by_transaction_name' as const) + : ('GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate' as const); + + const { data = INITIAL_STATE, status } = useFetcher( + (callApmApi) => { + if (transactionType && serviceName && start && end) { + return callApmApi(endpoint, { + params: { + path: { + serviceName, + }, + query: { + environment, + kuery, + start, + end, + transactionType, + comparisonStart, + comparisonEnd, + ...(transactionName ? { transactionName } : {}), + }, + }, + }); + } + }, + [ + environment, + kuery, + serviceName, + start, + end, + transactionType, + transactionName, + comparisonStart, + comparisonEnd, + endpoint, + ] + ); + + const timeseries = [ + { + data: data.currentPeriod.transactionColdstartRate, + type: 'linemark', + color: theme.eui.euiColorVis5, + title: i18n.translate('xpack.apm.coldstartRate.chart.coldstartRate', { + defaultMessage: 'Cold start rate (avg.)', + }), + }, + ...(comparisonEnabled + ? [ + { + data: data.previousPeriod.transactionColdstartRate, + type: 'area', + color: theme.eui.euiColorMediumShade, + title: i18n.translate( + 'xpack.apm.coldstartRate.chart.coldstartRate.previousPeriodLabel', + { defaultMessage: 'Previous period' } + ), + }, + ] + : []), + ]; + + return ( + + + + +

+ {i18n.translate('xpack.apm.coldstartRate', { + defaultMessage: 'Cold start rate', + })} +

+
+
+ + + + +
+ +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx index b5938167378d7..91780fec15845 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx @@ -5,7 +5,13 @@ * 2.0. */ -import { EuiBadge, EuiDescriptionList } from '@elastic/eui'; +import { + EuiBadge, + EuiDescriptionList, + EuiBetaBadge, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -16,9 +22,10 @@ type ServiceDetailsReturnType = interface Props { cloud: ServiceDetailsReturnType['cloud']; + isServerless: boolean; } -export function CloudDetails({ cloud }: Props) { +export function CloudDetails({ cloud, isServerless }: Props) { if (!cloud) { return null; } @@ -36,6 +43,43 @@ export function CloudDetails({ cloud }: Props) { }); } + if (cloud.serviceName) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.serviceNameLabel', + { + defaultMessage: 'Cloud service', + } + ), + description: ( + + {cloud.serviceName} + {isServerless && ( + + + + )} + + ), + }); + } + if (!!cloud.availabilityZones?.length) { listItems.push({ title: i18n.translate( @@ -58,7 +102,29 @@ export function CloudDetails({ cloud }: Props) { }); } - if (cloud.machineTypes) { + if (!!cloud.regions?.length) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.regionLabel', + { + defaultMessage: + '{regions, plural, =0 {Region} one {Region} other {Regions}} ', + values: { regions: cloud.regions.length }, + } + ), + description: ( +
    + {cloud.regions.map((region, index) => ( +
  • + {region} +
  • + ))} +
+ ), + }); + } + + if (!!cloud.machineTypes?.length) { listItems.push({ title: i18n.translate( 'xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel', diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx index 778cdeb9be3f7..2e15ea9f0db5f 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx @@ -189,6 +189,7 @@ describe('ServiceIcons', () => { data: { agentName: 'java', containerType: 'Kubernetes', + serverlessType: 'lambda', cloudProvider: 'gcp', }, status: fetcherHook.FETCH_STATUS.SUCCESS, @@ -220,6 +221,7 @@ describe('ServiceIcons', () => { expect(queryAllByTestId('loading')).toHaveLength(0); expect(getByTestId('service')).toBeInTheDocument(); expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('serverless')).toBeInTheDocument(); expect(getByTestId('cloud')).toBeInTheDocument(); fireEvent.click(getByTestId('popover_Service')); expect(getByTestId('loading-content')).toBeInTheDocument(); @@ -231,6 +233,7 @@ describe('ServiceIcons', () => { data: { agentName: 'java', containerType: 'Kubernetes', + serverlessType: '', cloudProvider: 'gcp', }, status: fetcherHook.FETCH_STATUS.SUCCESS, @@ -269,5 +272,118 @@ describe('ServiceIcons', () => { expect(getByText('Service')).toBeInTheDocument(); expect(getByText('v1.0.0')).toBeInTheDocument(); }); + + it('shows serverless content', () => { + const apisMockData = { + 'GET /internal/apm/services/{serviceName}/metadata/icons': { + data: { + agentName: 'java', + containerType: 'Kubernetes', + serverlessType: 'lambda', + cloudProvider: 'gcp', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + 'GET /internal/apm/services/{serviceName}/metadata/details': { + data: { + serverless: { + type: '', + functionNames: ['lambda-java-dev'], + faasTriggerTypes: ['datasource', 'http'], + }, + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + }; + jest + .spyOn(fetcherHook, 'useFetcher') + .mockImplementation((func: Function, deps: string[]) => { + return func(callApmApi(apisMockData)) || {}; + }); + + const { queryAllByTestId, getByTestId, getByText } = render( + + + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('serverless')).toBeInTheDocument(); + expect(getByTestId('cloud')).toBeInTheDocument(); + + fireEvent.click(getByTestId('popover_Serverless')); + expect(queryAllByTestId('loading-content')).toHaveLength(0); + expect(getByText('Serverless')).toBeInTheDocument(); + expect(getByText('lambda-java-dev')).toBeInTheDocument(); + expect(getByText('datasource')).toBeInTheDocument(); + expect(getByText('http')).toBeInTheDocument(); + }); + + it('shows cloud content', () => { + const apisMockData = { + 'GET /internal/apm/services/{serviceName}/metadata/icons': { + data: { + agentName: 'java', + containerType: 'Kubernetes', + serverlessType: 'lambda', + cloudProvider: 'gcp', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + 'GET /internal/apm/services/{serviceName}/metadata/details': { + data: { + cloud: { + provider: 'aws', + projectName: '', + serviceName: 'lambda', + availabilityZones: [], + regions: ['us-east-1'], + machineTypes: [], + }, + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + }; + jest + .spyOn(fetcherHook, 'useFetcher') + .mockImplementation((func: Function, deps: string[]) => { + return func(callApmApi(apisMockData)) || {}; + }); + + const { queryAllByTestId, getByTestId, getByText } = render( + + + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('serverless')).toBeInTheDocument(); + expect(getByTestId('cloud')).toBeInTheDocument(); + + fireEvent.click(getByTestId('popover_Cloud')); + expect(queryAllByTestId('loading-content')).toHaveLength(0); + expect(getByText('Cloud')).toBeInTheDocument(); + expect(getByText('aws')).toBeInTheDocument(); + expect(getByText('lambda')).toBeInTheDocument(); + expect(getByText('us-east-1')).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx index 52c5ca37d818e..2fd578f10b110 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx @@ -13,6 +13,7 @@ import { ContainerType } from '../../../../common/service_metadata'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { getAgentIcon } from '../agent_icon/get_agent_icon'; import { CloudDetails } from './cloud_details'; +import { ServerlessDetails } from './serverless_details'; import { ContainerDetails } from './container_details'; import { IconPopover } from './icon_popover'; import { ServiceDetails } from './service_details'; @@ -47,7 +48,7 @@ export function getContainerIcon(container?: ContainerType) { } } -type Icons = 'service' | 'container' | 'cloud' | 'alerts'; +type Icons = 'service' | 'container' | 'serverless' | 'cloud' | 'alerts'; export interface PopoverItem { key: Icons; @@ -130,6 +131,17 @@ export function ServiceIcons({ start, end, serviceName }: Props) { }), component: , }, + { + key: 'serverless', + icon: { + type: getAgentIcon(icons?.serverlessType, theme.darkMode) || 'node', + }, + isVisible: !!icons?.serverlessType, + title: i18n.translate('xpack.apm.serviceIcons.serverless', { + defaultMessage: 'Serverless', + }), + component: , + }, { key: 'cloud', icon: { @@ -139,7 +151,12 @@ export function ServiceIcons({ start, end, serviceName }: Props) { title: i18n.translate('xpack.apm.serviceIcons.cloud', { defaultMessage: 'Cloud', }), - component: , + component: ( + + ), }, ]; diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/serverless_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/serverless_details.tsx new file mode 100644 index 0000000000000..d05abab6d674a --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/service_icons/serverless_details.tsx @@ -0,0 +1,73 @@ +/* + * 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 { EuiBadge, EuiDescriptionList } from '@elastic/eui'; +import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { APIReturnType } from '../../../services/rest/create_call_apm_api'; + +type ServiceDetailsReturnType = + APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/details'>; + +interface Props { + serverless: ServiceDetailsReturnType['serverless']; +} + +export function ServerlessDetails({ serverless }: Props) { + if (!serverless) { + return null; + } + + const listItems: EuiDescriptionListProps['listItems'] = []; + + if (!!serverless.functionNames?.length) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.functionNameLabel', + { + defaultMessage: + '{functionNames, plural, =0 {Function name} one {Function name} other {Function names}} ', + values: { functionNames: serverless.functionNames.length }, + } + ), + description: ( +
    + {serverless.functionNames.map((type, index) => ( +
  • + {type} +
  • + ))} +
+ ), + }); + } + + if (!!serverless.faasTriggerTypes?.length) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel', + { + defaultMessage: + '{triggerTypes, plural, =0 {Trigger type} one {Trigger type} other {Trigger types}} ', + values: { triggerTypes: serverless.faasTriggerTypes.length }, + } + ), + description: ( +
    + {serverless.faasTriggerTypes.map((type, index) => ( +
  • + {type} +
  • + ))} +
+ ), + }); + } + + return ; +} diff --git a/x-pack/plugins/apm/public/components/shared/summary/transaction_summary.tsx b/x-pack/plugins/apm/public/components/shared/summary/transaction_summary.tsx index 399121b710ce9..7ceec331ae8fe 100644 --- a/x-pack/plugins/apm/public/components/shared/summary/transaction_summary.tsx +++ b/x-pack/plugins/apm/public/components/shared/summary/transaction_summary.tsx @@ -14,11 +14,13 @@ import { ErrorCountSummaryItemBadge } from './error_count_summary_item_badge'; import { HttpInfoSummaryItem } from './http_info_summary_item'; import { TransactionResultSummaryItem } from './transaction_result_summary_item'; import { UserAgentSummaryItem } from './user_agent_summary_item'; +import { ColdStartBadge } from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge'; interface Props { transaction: Transaction; totalDuration: number | undefined; errorCount: number; + coldStartBadge?: boolean; } function getTransactionResultSummaryItem(transaction: Transaction) { @@ -39,7 +41,12 @@ function getTransactionResultSummaryItem(transaction: Transaction) { return null; } -function TransactionSummary({ transaction, totalDuration, errorCount }: Props) { +function TransactionSummary({ + transaction, + totalDuration, + errorCount, + coldStartBadge, +}: Props) { const items = [ , ) : null, + coldStartBadge ? : null, ]; return ; diff --git a/x-pack/plugins/apm/server/lib/helpers/transaction_coldstart_rate.ts b/x-pack/plugins/apm/server/lib/helpers/transaction_coldstart_rate.ts new file mode 100644 index 0000000000000..094d5ed6350df --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/transaction_coldstart_rate.ts @@ -0,0 +1,67 @@ +/* + * 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 { FAAS_COLDSTART } from '../../../common/elasticsearch_fieldnames'; +import { + AggregationOptionsByType, + AggregationResultOf, +} from '../../../../../../src/core/types/elasticsearch'; + +export const getColdstartAggregation = () => ({ + terms: { + field: FAAS_COLDSTART, + }, +}); + +type ColdstartAggregation = ReturnType; + +export const getTimeseriesAggregation = ( + start: number, + end: number, + intervalString: string +) => ({ + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: { coldstartStates: getColdstartAggregation() }, +}); + +export function calculateTransactionColdstartRate( + coldstartStatesResponse: AggregationResultOf +) { + const coldstartStates = Object.fromEntries( + coldstartStatesResponse.buckets.map(({ key, doc_count: count }) => [ + key === 1 ? 'true' : 'false', + count, + ]) + ); + + const coldstarts = coldstartStates.true ?? 0; + const warmstarts = coldstartStates.false ?? 0; + + return coldstarts / (coldstarts + warmstarts); +} + +export function getTransactionColdstartRateTimeSeries( + buckets: AggregationResultOf< + { + date_histogram: AggregationOptionsByType['date_histogram']; + aggs: { coldstartStates: ColdstartAggregation }; + }, + {} + >['buckets'] +) { + return buckets.map((dateBucket) => { + return { + x: dateBucket.key, + y: calculateTransactionColdstartRate(dateBucket.coldstartStates), + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_coldstart_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_coldstart_rate.ts new file mode 100644 index 0000000000000..e7c9e111be7a5 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_coldstart_rate.ts @@ -0,0 +1,181 @@ +/* + * 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 { + FAAS_COLDSTART, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; +import { kqlQuery, rangeQuery } from '../../../../observability/server'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { Coordinate } from '../../../typings/timeseries'; +import { + getDocumentTypeFilterForTransactions, + getProcessorEventForTransactions, +} from '../helpers/transactions'; +import { getBucketSizeForAggregatedTransactions } from '../helpers/get_bucket_size_for_aggregated_transactions'; +import { Setup } from '../helpers/setup_request'; +import { + calculateTransactionColdstartRate, + getColdstartAggregation, + getTransactionColdstartRateTimeSeries, +} from '../helpers/transaction_coldstart_rate'; +import { termQuery } from '../../../../observability/server'; + +export async function getColdstartRate({ + environment, + kuery, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + start, + end, +}: { + environment: string; + kuery: string; + serviceName: string; + transactionType?: string; + transactionName: string; + setup: Setup; + searchAggregatedTransactions: boolean; + start: number; + end: number; +}): Promise<{ + transactionColdstartRate: Coordinate[]; + average: number | null; +}> { + const { apmEventClient } = setup; + + const filter = [ + ...termQuery(SERVICE_NAME, serviceName), + { exists: { field: FAAS_COLDSTART } }, + ...(transactionName ? termQuery(TRANSACTION_NAME, transactionName) : []), + ...termQuery(TRANSACTION_TYPE, transactionType), + ...getDocumentTypeFilterForTransactions(searchAggregatedTransactions), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ]; + + const coldstartStates = getColdstartAggregation(); + + const params = { + apm: { + events: [getProcessorEventForTransactions(searchAggregatedTransactions)], + }, + body: { + size: 0, + query: { bool: { filter } }, + aggs: { + coldstartStates, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSizeForAggregatedTransactions({ + start, + end, + searchAggregatedTransactions, + }).intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: { + coldstartStates, + }, + }, + }, + }, + }; + + const resp = await apmEventClient.search( + 'get_transaction_group_coldstart_rate', + params + ); + + if (!resp.aggregations) { + return { transactionColdstartRate: [], average: null }; + } + + const transactionColdstartRate = getTransactionColdstartRateTimeSeries( + resp.aggregations.timeseries.buckets + ); + + const average = calculateTransactionColdstartRate( + resp.aggregations.coldstartStates + ); + + return { transactionColdstartRate, average }; +} + +export async function getColdstartRatePeriods({ + environment, + kuery, + serviceName, + transactionType, + transactionName = '', + setup, + searchAggregatedTransactions, + comparisonStart, + comparisonEnd, + start, + end, +}: { + environment: string; + kuery: string; + serviceName: string; + transactionType?: string; + transactionName?: string; + setup: Setup; + searchAggregatedTransactions: boolean; + comparisonStart?: number; + comparisonEnd?: number; + start: number; + end: number; +}) { + const commonProps = { + environment, + kuery, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + }; + + const currentPeriodPromise = getColdstartRate({ ...commonProps, start, end }); + + const previousPeriodPromise = + comparisonStart && comparisonEnd + ? getColdstartRate({ + ...commonProps, + start: comparisonStart, + end: comparisonEnd, + }) + : { transactionColdstartRate: [], average: null }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + currentPeriodPromise, + previousPeriodPromise, + ]); + + const firstCurrentPeriod = currentPeriod.transactionColdstartRate; + + return { + currentPeriod, + previousPeriod: { + ...previousPeriod, + transactionColdstartRate: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firstCurrentPeriod, + previousPeriodTimeseries: previousPeriod.transactionColdstartRate, + }), + }, + }; +} diff --git a/x-pack/plugins/apm/server/routes/services/get_service_metadata_details.ts b/x-pack/plugins/apm/server/routes/services/get_service_metadata_details.ts index b7ce68a0de578..6b7f16084e5f3 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_metadata_details.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_metadata_details.ts @@ -10,7 +10,9 @@ import { AGENT, CLOUD, CLOUD_AVAILABILITY_ZONE, + CLOUD_REGION, CLOUD_MACHINE_TYPE, + CLOUD_SERVICE_NAME, CONTAINER_ID, HOST, KUBERNETES, @@ -18,6 +20,8 @@ import { SERVICE_NAME, SERVICE_NODE_NAME, SERVICE_VERSION, + FAAS_ID, + FAAS_TRIGGER_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { ContainerType } from '../../../common/service_metadata'; import { rangeQuery } from '../../../../observability/server'; @@ -50,11 +54,18 @@ export interface ServiceMetadataDetails { totalNumberInstances?: number; type?: ContainerType; }; + serverless?: { + type?: string; + functionNames?: string[]; + faasTriggerTypes?: string[]; + }; cloud?: { provider?: string; availabilityZones?: string[]; + regions?: string[]; machineTypes?: string[]; projectName?: string; + serviceName?: string; }; } @@ -104,12 +115,36 @@ export async function getServiceMetadataDetails({ size: 10, }, }, + regions: { + terms: { + field: CLOUD_REGION, + size: 10, + }, + }, + cloudServices: { + terms: { + field: CLOUD_SERVICE_NAME, + size: 1, + }, + }, machineTypes: { terms: { field: CLOUD_MACHINE_TYPE, size: 10, }, }, + faasTriggerTypes: { + terms: { + field: FAAS_TRIGGER_TYPE, + size: 10, + }, + }, + faasFunctionNames: { + terms: { + field: FAAS_ID, + size: 10, + }, + }, totalNumberInstances: { cardinality: { field: SERVICE_NODE_NAME } }, }, }, @@ -153,13 +188,30 @@ export async function getServiceMetadataDetails({ } : undefined; + const serverlessDetails = + !!response.aggregations?.faasTriggerTypes?.buckets.length && cloud + ? { + type: cloud.service?.name, + functionNames: response.aggregations?.faasFunctionNames.buckets + .map((bucket) => getLambdaFunctionNameFromARN(bucket.key as string)) + .filter((name) => name), + faasTriggerTypes: response.aggregations?.faasTriggerTypes.buckets.map( + (bucket) => bucket.key as string + ), + } + : undefined; + const cloudDetails = cloud ? { provider: cloud.provider, projectName: cloud.project?.name, + serviceName: cloud.service?.name, availabilityZones: response.aggregations?.availabilityZones.buckets.map( (bucket) => bucket.key as string ), + regions: response.aggregations?.regions.buckets.map( + (bucket) => bucket.key as string + ), machineTypes: response.aggregations?.machineTypes.buckets.map( (bucket) => bucket.key as string ), @@ -169,6 +221,12 @@ export async function getServiceMetadataDetails({ return { service: serviceMetadataDetails, container: containerDetails, + serverless: serverlessDetails, cloud: cloudDetails, }; } + +function getLambdaFunctionNameFromARN(arn: string) { + // Lambda function ARN example: arn:aws:lambda:us-west-2:123456789012:function:my-function + return arn.split(':')[6] || ''; +} diff --git a/x-pack/plugins/apm/server/routes/services/get_service_metadata_icons.ts b/x-pack/plugins/apm/server/routes/services/get_service_metadata_icons.ts index ca97e9d58f060..6082ae6c5b112 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_metadata_icons.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_metadata_icons.ts @@ -9,6 +9,7 @@ import { ProcessorEvent } from '../../../common/processor_event'; import { AGENT_NAME, CLOUD_PROVIDER, + CLOUD_SERVICE_NAME, CONTAINER_ID, KUBERNETES, SERVICE_NAME, @@ -29,6 +30,7 @@ type ServiceMetadataIconsRaw = Pick< export interface ServiceMetadataIcons { agentName?: string; containerType?: ContainerType; + serverlessType?: string; cloudProvider?: string; } @@ -70,7 +72,13 @@ export async function getServiceMetadataIcons({ }, body: { size: 1, - _source: [KUBERNETES, CLOUD_PROVIDER, CONTAINER_ID, AGENT_NAME], + _source: [ + KUBERNETES, + CLOUD_PROVIDER, + CONTAINER_ID, + AGENT_NAME, + CLOUD_SERVICE_NAME, + ], query: { bool: { filter, should } }, }, }; @@ -85,6 +93,7 @@ export async function getServiceMetadataIcons({ agentName: undefined, containerType: undefined, cloudProvider: undefined, + serverlessType: undefined, }; } @@ -98,9 +107,15 @@ export async function getServiceMetadataIcons({ containerType = 'Docker'; } + let serverlessType: string | undefined; + if (cloud?.provider === 'aws' && cloud?.service?.name === 'lambda') { + serverlessType = 'lambda'; + } + return { agentName: agent?.name, containerType, + serverlessType, cloudProvider: cloud?.provider, }; } diff --git a/x-pack/plugins/apm/server/routes/transactions/route.ts b/x-pack/plugins/apm/server/routes/transactions/route.ts index e22e521c699ec..657387037855f 100644 --- a/x-pack/plugins/apm/server/routes/transactions/route.ts +++ b/x-pack/plugins/apm/server/routes/transactions/route.ts @@ -19,6 +19,7 @@ import { getTransactionBreakdown } from './breakdown'; import { getTransactionTraceSamples } from './trace_samples'; import { getLatencyPeriods } from './get_latency_charts'; import { getFailedTransactionRatePeriods } from './get_failed_transaction_rate_periods'; +import { getColdstartRatePeriods } from '../../lib/transaction_groups/get_coldstart_rate'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { comparisonRangeRt, @@ -461,6 +462,158 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ }, }); +const transactionChartsColdstartRateRoute = createApmServerRoute({ + endpoint: + 'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + t.type({ transactionType: t.string }), + t.intersection([environmentRt, kueryRt, rangeRt, comparisonRangeRt]), + ]), + }), + options: { tags: ['access:apm'] }, + handler: async ( + resources + ): Promise<{ + currentPeriod: { + transactionColdstartRate: Array< + import('../../../typings/timeseries').Coordinate + >; + average: number | null; + }; + previousPeriod: + | { + transactionColdstartRate: Array<{ + x: number; + y: import('../../../typings/common').Maybe; + }>; + average: number | null; + } + | { + transactionColdstartRate: Array<{ + x: number; + y: import('../../../typings/common').Maybe; + }>; + average: null; + }; + }> => { + const setup = await setupRequest(resources); + + const { params } = resources; + const { serviceName } = params.path; + const { + environment, + kuery, + transactionType, + comparisonStart, + comparisonEnd, + start, + end, + } = params.query; + + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + start, + end, + }); + + return getColdstartRatePeriods({ + environment, + kuery, + serviceName, + transactionType, + setup, + searchAggregatedTransactions, + comparisonStart, + comparisonEnd, + start, + end, + }); + }, +}); + +const transactionChartsColdstartRateByTransactionNameRoute = + createApmServerRoute({ + endpoint: + 'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate_by_transaction_name', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + t.type({ transactionType: t.string, transactionName: t.string }), + t.intersection([environmentRt, kueryRt, rangeRt, comparisonRangeRt]), + ]), + }), + options: { tags: ['access:apm'] }, + handler: async ( + resources + ): Promise<{ + currentPeriod: { + transactionColdstartRate: Array< + import('../../../typings/timeseries').Coordinate + >; + average: number | null; + }; + previousPeriod: + | { + transactionColdstartRate: Array<{ + x: number; + y: import('../../../typings/common').Maybe; + }>; + average: number | null; + } + | { + transactionColdstartRate: Array<{ + x: number; + y: import('../../../typings/common').Maybe; + }>; + average: null; + }; + }> => { + const setup = await setupRequest(resources); + + const { params } = resources; + const { serviceName } = params.path; + const { + environment, + kuery, + transactionType, + transactionName, + comparisonStart, + comparisonEnd, + start, + end, + } = params.query; + + const searchAggregatedTransactions = + await getSearchAggregatedTransactions({ + ...setup, + kuery, + start, + end, + }); + + return getColdstartRatePeriods({ + environment, + kuery, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + comparisonStart, + comparisonEnd, + start, + end, + }); + }, + }); + export const transactionRouteRepository = { ...transactionGroupsMainStatisticsRoute, ...transactionGroupsDetailedStatisticsRoute, @@ -468,4 +621,6 @@ export const transactionRouteRepository = { ...transactionTraceSamplesRoute, ...transactionChartsBreakdownRoute, ...transactionChartsErrorRateRoute, + ...transactionChartsColdstartRateRoute, + ...transactionChartsColdstartRateByTransactionNameRoute, }; diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts index 8891318554e92..bc0c3ea8002ad 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts @@ -27,4 +27,7 @@ export interface Cloud { image?: { id: string; }; + service?: { + name: string; + }; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/faas.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/faas.ts new file mode 100644 index 0000000000000..1229b8134ac13 --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/faas.ts @@ -0,0 +1,16 @@ +/* + * 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 interface Faas { + id: string; + coldstart?: boolean; + execution?: string; + trigger?: { + type?: string; + request_id?: string; + }; +} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index 34c391134b604..0811bfb8c1a79 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -19,6 +19,7 @@ import { TimestampUs } from './fields/timestamp_us'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; +import { Faas } from './fields/faas'; interface Processor { name: 'transaction'; @@ -69,4 +70,5 @@ export interface TransactionRaw extends APMBaseDoc { user?: User; user_agent?: UserAgent; cloud?: Cloud; + faas?: Faas; } diff --git a/x-pack/test/apm_api_integration/tests/cold_start/cold_start.spec.ts b/x-pack/test/apm_api_integration/tests/cold_start/cold_start.spec.ts new file mode 100644 index 0000000000000..fedf44d477d2d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/cold_start/cold_start.spec.ts @@ -0,0 +1,205 @@ +/* + * 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 { first, last } from 'lodash'; +import moment from 'moment'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { dataConfig, generateData } from './generate_data'; +import { + APIReturnType, + APIClientRequestParamsOf, +} from '../../../../plugins/apm/public/services/rest/create_call_apm_api'; +import { RecursivePartial } from '../../../../plugins/apm/typings/common'; +import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; + +type ColdStartRate = + APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const { serviceName } = dataConfig; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi( + overrides?: RecursivePartial< + APIClientRequestParamsOf<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate'>['params'] + > + ) { + return await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate', + params: { + path: { serviceName }, + query: { + transactionType: 'request', + environment: 'ENVIRONMENT_ALL', + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + kuery: '', + ...overrides?.query, + }, + }, + }); + } + + registry.when( + 'Cold start rate when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body.currentPeriod.transactionColdstartRate).to.empty(); + expect(body.currentPeriod.average).to.be(null); + + expect(body.previousPeriod.transactionColdstartRate).to.empty(); + expect(body.previousPeriod.average).to.be(null); + }); + } + ); + + registry.when( + 'Cold start rate when data is generated', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('without comparison', () => { + let body: ColdStartRate; + let status: number; + + before(async () => { + await generateData({ + synthtraceEsClient, + start, + end, + coldStartRate: 10, + warmStartRate: 30, + }); + const response = await callApi(); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns an array of transaction cold start rates', () => { + expect(body).to.have.property('currentPeriod'); + expect(body.currentPeriod.transactionColdstartRate).to.have.length(15); + expect(body.currentPeriod.transactionColdstartRate.every(({ y }) => y === 0.25)).to.be( + true + ); + }); + + it('returns correct average rate', () => { + expect(body.currentPeriod.average).to.be(0.25); + }); + + it("doesn't have data for the previous period", () => { + expect(body).to.have.property('previousPeriod'); + expect(body.previousPeriod.transactionColdstartRate).to.have.length(0); + expect(body.previousPeriod.average).to.be(null); + }); + }); + + describe('with comparison', () => { + let body: ColdStartRate; + let status: number; + + before(async () => { + const startDate = moment(start).add(6, 'minutes'); + const endDate = moment(start).add(9, 'minutes'); + const comparisonStartDate = new Date(start); + const comparisonEndDate = moment(start).add(3, 'minutes'); + + await generateData({ + synthtraceEsClient, + start: startDate.valueOf(), + end: endDate.valueOf(), + coldStartRate: 10, + warmStartRate: 30, + }); + await generateData({ + synthtraceEsClient, + start: comparisonStartDate.getTime(), + end: comparisonEndDate.valueOf(), + coldStartRate: 20, + warmStartRate: 20, + }); + + const response = await callApi({ + query: { + start: startDate.toISOString(), + end: endDate.subtract(1, 'seconds').toISOString(), + comparisonStart: comparisonStartDate.toISOString(), + comparisonEnd: comparisonEndDate.subtract(1, 'seconds').toISOString(), + }, + }); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns some data', () => { + expect(body.currentPeriod.average).not.to.be(null); + expect(body.currentPeriod.transactionColdstartRate.length).to.be.greaterThan(0); + const hasCurrentPeriodData = body.currentPeriod.transactionColdstartRate.some(({ y }) => + isFiniteNumber(y) + ); + expect(hasCurrentPeriodData).to.equal(true); + + expect(body.previousPeriod.average).not.to.be(null); + expect(body.previousPeriod.transactionColdstartRate.length).to.be.greaterThan(0); + const hasPreviousPeriodData = body.previousPeriod.transactionColdstartRate.some(({ y }) => + isFiniteNumber(y) + ); + expect(hasPreviousPeriodData).to.equal(true); + }); + + it('has same start time for both periods', () => { + expect(first(body.currentPeriod.transactionColdstartRate)?.x).to.equal( + first(body.previousPeriod.transactionColdstartRate)?.x + ); + }); + + it('has same end time for both periods', () => { + expect(last(body.currentPeriod.transactionColdstartRate)?.x).to.equal( + last(body.previousPeriod.transactionColdstartRate)?.x + ); + }); + + it('returns an array of transaction cold start rates', () => { + expect(body.currentPeriod.transactionColdstartRate).to.have.length(3); + expect(body.currentPeriod.transactionColdstartRate.every(({ y }) => y === 0.25)).to.be( + true + ); + + expect(body.previousPeriod.transactionColdstartRate).to.have.length(3); + expect(body.previousPeriod.transactionColdstartRate.every(({ y }) => y === 0.5)).to.be( + true + ); + }); + + it('has same average value for both periods', () => { + expect(body.currentPeriod.average).to.be(0.25); + expect(body.previousPeriod.average).to.be(0.5); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/cold_start_by_transaction_name.spec.ts b/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/cold_start_by_transaction_name.spec.ts new file mode 100644 index 0000000000000..d577077490b88 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/cold_start_by_transaction_name.spec.ts @@ -0,0 +1,207 @@ +/* + * 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 { first, last } from 'lodash'; +import moment from 'moment'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { dataConfig, generateData } from './generate_data'; +import { + APIReturnType, + APIClientRequestParamsOf, +} from '../../../../../plugins/apm/public/services/rest/create_call_apm_api'; +import { RecursivePartial } from '../../../../../plugins/apm/typings/common'; +import { isFiniteNumber } from '../../../../../plugins/apm/common/utils/is_finite_number'; + +type ColdStartRate = + APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate_by_transaction_name'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const { serviceName, transactionName } = dataConfig; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi( + overrides?: RecursivePartial< + APIClientRequestParamsOf<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate'>['params'] + > + ) { + return await apmApiClient.readUser({ + endpoint: + 'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate_by_transaction_name', + params: { + path: { serviceName }, + query: { + transactionType: 'request', + transactionName, + environment: 'ENVIRONMENT_ALL', + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + kuery: '', + ...overrides?.query, + }, + }, + }); + } + + registry.when( + 'Cold start rate by transaction name when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body.currentPeriod.transactionColdstartRate).to.empty(); + expect(body.currentPeriod.average).to.be(null); + + expect(body.previousPeriod.transactionColdstartRate).to.empty(); + expect(body.previousPeriod.average).to.be(null); + }); + } + ); + + registry.when( + 'Cold start rate by transaction name when data is generated', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('without comparison', () => { + let body: ColdStartRate; + let status: number; + + before(async () => { + await generateData({ + synthtraceEsClient, + start, + end, + coldStartRate: 10, + warmStartRate: 30, + }); + const response = await callApi(); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns an array of transaction cold start rates', () => { + expect(body).to.have.property('currentPeriod'); + expect(body.currentPeriod.transactionColdstartRate).to.have.length(15); + expect(body.currentPeriod.transactionColdstartRate.every(({ y }) => y === 0.25)).to.be( + true + ); + }); + + it('returns correct average rate', () => { + expect(body.currentPeriod.average).to.be(0.25); + }); + + it("doesn't have data for the previous period", () => { + expect(body).to.have.property('previousPeriod'); + expect(body.previousPeriod.transactionColdstartRate).to.have.length(0); + expect(body.previousPeriod.average).to.be(null); + }); + }); + + describe('with comparison', () => { + let body: ColdStartRate; + let status: number; + + before(async () => { + const startDate = moment(start).add(6, 'minutes'); + const endDate = moment(start).add(9, 'minutes'); + const comparisonStartDate = new Date(start); + const comparisonEndDate = moment(start).add(3, 'minutes'); + + await generateData({ + synthtraceEsClient, + start: startDate.valueOf(), + end: endDate.valueOf(), + coldStartRate: 10, + warmStartRate: 30, + }); + await generateData({ + synthtraceEsClient, + start: comparisonStartDate.getTime(), + end: comparisonEndDate.valueOf(), + coldStartRate: 20, + warmStartRate: 20, + }); + + const response = await callApi({ + query: { + start: startDate.toISOString(), + end: endDate.subtract(1, 'seconds').toISOString(), + comparisonStart: comparisonStartDate.toISOString(), + comparisonEnd: comparisonEndDate.subtract(1, 'seconds').toISOString(), + }, + }); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns some data', () => { + expect(body.currentPeriod.average).not.to.be(null); + expect(body.currentPeriod.transactionColdstartRate.length).to.be.greaterThan(0); + const hasCurrentPeriodData = body.currentPeriod.transactionColdstartRate.some(({ y }) => + isFiniteNumber(y) + ); + expect(hasCurrentPeriodData).to.equal(true); + + expect(body.previousPeriod.average).not.to.be(null); + expect(body.previousPeriod.transactionColdstartRate.length).to.be.greaterThan(0); + const hasPreviousPeriodData = body.previousPeriod.transactionColdstartRate.some(({ y }) => + isFiniteNumber(y) + ); + expect(hasPreviousPeriodData).to.equal(true); + }); + + it('has same start time for both periods', () => { + expect(first(body.currentPeriod.transactionColdstartRate)?.x).to.equal( + first(body.previousPeriod.transactionColdstartRate)?.x + ); + }); + + it('has same end time for both periods', () => { + expect(last(body.currentPeriod.transactionColdstartRate)?.x).to.equal( + last(body.previousPeriod.transactionColdstartRate)?.x + ); + }); + + it('returns an array of transaction cold start rates', () => { + expect(body.currentPeriod.transactionColdstartRate).to.have.length(3); + expect(body.currentPeriod.transactionColdstartRate.every(({ y }) => y === 0.25)).to.be( + true + ); + + expect(body.previousPeriod.transactionColdstartRate).to.have.length(3); + expect(body.previousPeriod.transactionColdstartRate.every(({ y }) => y === 0.5)).to.be( + true + ); + }); + + it('has same average value for both periods', () => { + expect(body.currentPeriod.average).to.be(0.25); + expect(body.previousPeriod.average).to.be(0.5); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/generate_data.ts b/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/generate_data.ts new file mode 100644 index 0000000000000..e081c5ea2e168 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/generate_data.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 { apm, timerange } from '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export const dataConfig = { + serviceName: 'synth-go', + transactionName: 'GET /apple 🍎', + duration: 1000, +}; + +export async function generateData({ + synthtraceEsClient, + start, + end, + coldStartRate, + warmStartRate, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; + coldStartRate: number; + warmStartRate: number; +}) { + const { transactionName, duration, serviceName } = dataConfig; + const instance = apm.service(serviceName, 'production', 'go').instance('instance-a'); + + const traceEvents = [ + ...timerange(start, end) + .interval('1m') + .rate(coldStartRate) + .flatMap((timestamp) => [ + ...instance + .transaction(transactionName) + .defaults({ + 'faas.coldstart': true, + }) + .timestamp(timestamp) + .duration(duration) + .success() + .serialize(), + ]), + ...timerange(start, end) + .interval('1m') + .rate(warmStartRate) + .flatMap((timestamp) => [ + ...instance + .transaction(transactionName) + .defaults({ + 'faas.coldstart': false, + }) + .timestamp(timestamp) + .duration(duration) + .success() + .serialize(), + ]), + ]; + + await synthtraceEsClient.index(traceEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/cold_start/generate_data.ts b/x-pack/test/apm_api_integration/tests/cold_start/generate_data.ts new file mode 100644 index 0000000000000..50861da61a9f7 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/cold_start/generate_data.ts @@ -0,0 +1,70 @@ +/* + * 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 { apm, timerange } from '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export const dataConfig = { + serviceName: 'synth-go', + coldStartTransaction: { + name: 'GET /apple 🍎', + duration: 1000, + }, + warmStartTransaction: { + name: 'GET /banana 🍌', + duration: 2000, + }, +}; + +export async function generateData({ + synthtraceEsClient, + start, + end, + coldStartRate, + warmStartRate, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; + coldStartRate: number; + warmStartRate: number; +}) { + const { coldStartTransaction, warmStartTransaction, serviceName } = dataConfig; + const instance = apm.service(serviceName, 'production', 'go').instance('instance-a'); + + const traceEvents = [ + ...timerange(start, end) + .interval('1m') + .rate(coldStartRate) + .flatMap((timestamp) => [ + ...instance + .transaction(coldStartTransaction.name) + .defaults({ + 'faas.coldstart': true, + }) + .timestamp(timestamp) + .duration(coldStartTransaction.duration) + .success() + .serialize(), + ]), + ...timerange(start, end) + .interval('1m') + .rate(warmStartRate) + .flatMap((timestamp) => [ + ...instance + .transaction(warmStartTransaction.name) + .defaults({ + 'faas.coldstart': false, + }) + .timestamp(timestamp) + .duration(warmStartTransaction.duration) + .success() + .serialize(), + ]), + ]; + + await synthtraceEsClient.index(traceEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/services/service_details.spec.ts b/x-pack/test/apm_api_integration/tests/services/service_details.spec.ts deleted file mode 100644 index 0031569308224..0000000000000 --- a/x-pack/test/apm_api_integration/tests/services/service_details.spec.ts +++ /dev/null @@ -1,135 +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 expect from '@kbn/expect'; -import url from 'url'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import archives from '../../common/fixtures/es_archiver/archives_metadata'; - -export default function ApiTest({ getService }: FtrProviderContext) { - const registry = getService('registry'); - const supertest = getService('legacySupertestAsApmReadUser'); - - const archiveName = 'apm_8.0.0'; - const { start, end } = archives[archiveName]; - - registry.when( - 'Service details when data is not loaded', - { config: 'basic', archives: [] }, - () => { - it('handles the empty state', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/metadata/details`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - expect(response.body).to.eql({}); - }); - } - ); - - registry.when( - 'Service details when data is loaded', - { config: 'basic', archives: [archiveName] }, - () => { - it('returns java service details', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/metadata/details`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body).toMatchInline(` - Object { - "cloud": Object { - "availabilityZones": Array [ - "europe-west1-c", - ], - "machineTypes": Array [ - "n1-standard-4", - ], - "projectName": "elastic-observability", - "provider": "gcp", - }, - "container": Object { - "isContainerized": true, - "os": "Linux", - "totalNumberInstances": 1, - "type": "Kubernetes", - }, - "service": Object { - "agent": Object { - "ephemeral_id": "2745d454-f57f-4473-a09b-fe6bef295860", - "name": "java", - "version": "1.25.1-SNAPSHOT.UNKNOWN", - }, - "runtime": Object { - "name": "Java", - "version": "11.0.11", - }, - "versions": Array [ - "2021-08-03 04:26:27", - ], - }, - } - `); - }); - - it('returns python service details', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-python/metadata/details`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body).toMatchInline(` - Object { - "cloud": Object { - "availabilityZones": Array [ - "europe-west1-c", - ], - "machineTypes": Array [ - "n1-standard-4", - ], - "projectName": "elastic-observability", - "provider": "gcp", - }, - "container": Object { - "isContainerized": true, - "os": "linux", - "totalNumberInstances": 1, - "type": "Kubernetes", - }, - "service": Object { - "agent": Object { - "name": "python", - "version": "6.3.3", - }, - "framework": "django", - "runtime": Object { - "name": "CPython", - "version": "3.9.6", - }, - "versions": Array [ - "2021-08-03 04:26:25", - ], - }, - } - `); - }); - } - ); -} diff --git a/x-pack/test/apm_api_integration/tests/services/service_details/generate_data.ts b/x-pack/test/apm_api_integration/tests/services/service_details/generate_data.ts new file mode 100644 index 0000000000000..f6035cf784dbd --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/service_details/generate_data.ts @@ -0,0 +1,130 @@ +/* + * 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 { apm, timerange } from '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export const dataConfig = { + rate: 10, + transaction: { + name: 'GET /apple 🍎', + duration: 1000, + }, + service: { + name: 'lambda-python-dev-hello', + version: '$LATEST', + runtime: { + name: 'AWS_Lambda_python3.8', + version: '3.8.11', + }, + framework: 'AWS Lambda', + agent: { + name: 'python', + version: '6.6.0', + }, + }, + containerOs: 'linux', + serverless: { + firstFunctionName: 'my-function-1', + secondFunctionName: 'my-function-2', + faasTriggerType: 'other', + }, + cloud: { + provider: 'aws', + availabilityZone: 'us-central1-c', + region: 'us-east-1', + machineType: 'e2-standard-4', + projectName: 'elastic-observability', + serviceName: 'lambda', + }, +}; + +export async function generateData({ + synthtraceEsClient, + start, + end, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; +}) { + const { rate, service, containerOs, serverless, cloud, transaction } = dataConfig; + const { + provider, + availabilityZone, + region, + machineType, + projectName, + serviceName: cloudServiceName, + } = cloud; + const { faasTriggerType, firstFunctionName, secondFunctionName } = serverless; + const { version, runtime, framework, agent, name: serviceName } = service; + const { name: serviceRunTimeName, version: serviceRunTimeVersion } = runtime; + const { name: agentName, version: agentVersion } = agent; + + const instance = apm.service(serviceName, 'production', agentName).instance('instance-a'); + + const traceEvents = [ + ...timerange(start, end) + .interval('30s') + .rate(rate) + .flatMap((timestamp) => + instance + .transaction(transaction.name) + .timestamp(timestamp) + .defaults({ + 'cloud.provider': provider, + 'cloud.project.name': projectName, + 'cloud.service.name': cloudServiceName, + 'cloud.availability_zone': availabilityZone, + 'cloud.machine.type': machineType, + 'cloud.region': region, + 'faas.id': `arn:aws:lambda:us-west-2:123456789012:function:${firstFunctionName}`, + 'faas.trigger.type': faasTriggerType, + 'host.os.platform': containerOs, + 'kubernetes.pod.uid': '48f4c5a5-0625-4bea-9d94-77ee94a17e70', + 'service.version': version, + 'service.runtime.name': serviceRunTimeName, + 'service.runtime.version': serviceRunTimeVersion, + 'service.framework.name': framework, + 'agent.version': agentVersion, + }) + .duration(transaction.duration) + .success() + .serialize() + ), + ...timerange(start, end) + .interval('30s') + .rate(rate) + .flatMap((timestamp) => + instance + .transaction(transaction.name) + .timestamp(timestamp) + .defaults({ + 'cloud.provider': provider, + 'cloud.project.name': projectName, + 'cloud.service.name': cloudServiceName, + 'cloud.availability_zone': availabilityZone, + 'cloud.machine.type': machineType, + 'cloud.region': region, + 'faas.id': `arn:aws:lambda:us-west-2:123456789012:function:${secondFunctionName}`, + 'faas.trigger.type': faasTriggerType, + 'host.os.platform': containerOs, + 'kubernetes.pod.uid': '48f4c5a5-0625-4bea-9d94-77ee94a17e70', + 'service.version': version, + 'service.runtime.name': serviceRunTimeName, + 'service.runtime.version': serviceRunTimeVersion, + 'service.framework.name': framework, + 'agent.version': agentVersion, + }) + .duration(transaction.duration) + .success() + .serialize() + ), + ]; + + await synthtraceEsClient.index(traceEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/services/service_details/service_details.spec.ts b/x-pack/test/apm_api_integration/tests/services/service_details/service_details.spec.ts new file mode 100644 index 0000000000000..cf59b0d39660d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/service_details/service_details.spec.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 { first } from 'lodash'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { dataConfig, generateData } from './generate_data'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/create_call_apm_api'; + +type ServiceDetails = APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/details'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const { + service: { name: serviceName }, + } = dataConfig; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi() { + return await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/metadata/details', + params: { + path: { serviceName }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }, + }, + }); + } + + registry.when( + 'Service details when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body).to.empty(); + }); + } + ); + + registry.when( + 'Service details when data is generated', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + let body: ServiceDetails; + let status: number; + + before(async () => { + await generateData({ synthtraceEsClient, start, end }); + const response = await callApi(); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns correct cloud details', () => { + const { cloud } = dataConfig; + const { + provider, + availabilityZone, + region, + machineType, + projectName, + serviceName: cloudServiceName, + } = cloud; + + expect(first(body?.cloud?.availabilityZones)).to.be(availabilityZone); + expect(first(body?.cloud?.machineTypes)).to.be(machineType); + expect(body?.cloud?.provider).to.be(provider); + expect(body?.cloud?.projectName).to.be(projectName); + expect(body?.cloud?.serviceName).to.be(cloudServiceName); + expect(first(body?.cloud?.regions)).to.be(region); + }); + + it('returns correct container details', () => { + const { containerOs } = dataConfig; + + expect(body?.container?.isContainerized).to.be(true); + expect(body?.container?.os).to.be(containerOs); + expect(body?.container?.totalNumberInstances).to.be(1); + expect(body?.container?.type).to.be('Kubernetes'); + }); + + it('returns correct serverless details', () => { + const { cloud, serverless } = dataConfig; + const { serviceName: cloudServiceName } = cloud; + const { faasTriggerType, firstFunctionName, secondFunctionName } = serverless; + + expect(body?.serverless?.type).to.be(cloudServiceName); + expect(body?.serverless?.functionNames).to.have.length(2); + expect(body?.serverless?.functionNames).to.contain(firstFunctionName); + expect(body?.serverless?.functionNames).to.contain(secondFunctionName); + expect(first(body?.serverless?.faasTriggerTypes)).to.be(faasTriggerType); + }); + + it('returns correct service details', () => { + const { service } = dataConfig; + const { version, runtime, framework, agent } = service; + const { name: runTimeName, version: runTimeVersion } = runtime; + const { name: agentName, version: agentVersion } = agent; + + expect(body?.service?.framework).to.be(framework); + expect(body?.service?.agent.name).to.be(agentName); + expect(body?.service?.agent.version).to.be(agentVersion); + expect(body?.service?.runtime?.name).to.be(runTimeName); + expect(body?.service?.runtime?.version).to.be(runTimeVersion); + expect(first(body?.service?.versions)).to.be(version); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/services/service_icons.spec.ts b/x-pack/test/apm_api_integration/tests/services/service_icons.spec.ts deleted file mode 100644 index f8b66a621e83e..0000000000000 --- a/x-pack/test/apm_api_integration/tests/services/service_icons.spec.ts +++ /dev/null @@ -1,77 +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 expect from '@kbn/expect'; -import url from 'url'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import archives from '../../common/fixtures/es_archiver/archives_metadata'; - -export default function ApiTest({ getService }: FtrProviderContext) { - const registry = getService('registry'); - const supertest = getService('legacySupertestAsApmReadUser'); - - const archiveName = 'apm_8.0.0'; - const { start, end } = archives[archiveName]; - - registry.when('Service icons when data is not loaded', { config: 'basic', archives: [] }, () => { - it('handles the empty state', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/metadata/icons`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - expect(response.body).to.eql({}); - }); - }); - - registry.when( - 'Service icons when data is not loaded', - { config: 'basic', archives: [archiveName] }, - () => { - it('returns java service icons', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/metadata/icons`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body).toMatchInline(` - Object { - "agentName": "java", - "cloudProvider": "gcp", - "containerType": "Kubernetes", - } - `); - }); - - it('returns python service icons', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-python/metadata/icons`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body).toMatchInline(` - Object { - "agentName": "python", - "cloudProvider": "gcp", - "containerType": "Kubernetes", - } - `); - }); - } - ); -} diff --git a/x-pack/test/apm_api_integration/tests/services/service_icons/generate_data.ts b/x-pack/test/apm_api_integration/tests/services/service_icons/generate_data.ts new file mode 100644 index 0000000000000..93f68242caa5e --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/service_icons/generate_data.ts @@ -0,0 +1,56 @@ +/* + * 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 { apm, timerange } from '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export const dataConfig = { + serviceName: 'synth-node', + rate: 10, + transaction: { + name: 'GET /apple 🍎', + duration: 1000, + }, + agentName: 'node', + cloud: { + provider: 'aws', + serviceName: 'lambda', + }, +}; + +export async function generateData({ + synthtraceEsClient, + start, + end, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; +}) { + const { serviceName, agentName, rate, cloud, transaction } = dataConfig; + const { provider, serviceName: cloudServiceName } = cloud; + + const instance = apm.service(serviceName, 'production', agentName).instance('instance-a'); + + const traceEvents = timerange(start, end) + .interval('30s') + .rate(rate) + .flatMap((timestamp) => + instance + .transaction(transaction.name) + .defaults({ + 'kubernetes.pod.uid': 'test', + 'cloud.provider': provider, + 'cloud.service.name': cloudServiceName, + }) + .timestamp(timestamp) + .duration(transaction.duration) + .success() + .serialize() + ); + + await synthtraceEsClient.index(traceEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/services/service_icons/service_icons.spec.ts b/x-pack/test/apm_api_integration/tests/services/service_icons/service_icons.spec.ts new file mode 100644 index 0000000000000..389b8c6ac1fc8 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/service_icons/service_icons.spec.ts @@ -0,0 +1,77 @@ +/* + * 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 '../../../common/ftr_provider_context'; +import { dataConfig, generateData } from './generate_data'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/create_call_apm_api'; + +type ServiceIconMetadata = APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/icons'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const { serviceName } = dataConfig; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi() { + return await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/metadata/icons', + params: { + path: { serviceName }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }, + }, + }); + } + + registry.when('Service icons when data is not loaded', { config: 'basic', archives: [] }, () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body).to.empty(); + }); + }); + + registry.when( + 'Service icons when data is generated', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + let body: ServiceIconMetadata; + let status: number; + + before(async () => { + await generateData({ synthtraceEsClient, start, end }); + const response = await callApi(); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns correct metadata', () => { + const { agentName, cloud } = dataConfig; + const { provider, serviceName: cloudServiceName } = cloud; + + expect(body.agentName).to.be(agentName); + expect(body.cloudProvider).to.be(provider); + expect(body.containerType).to.be('Kubernetes'); + expect(body.serverlessType).to.be(cloudServiceName); + }); + } + ); +} From b6d2149e99c7c0bdcf490b501e8081d806f5d4ad Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 31 Jan 2022 13:04:32 +0100 Subject: [PATCH 11/65] Add configuration to search without `bfetch` (#123942) --- src/plugins/bfetch/common/constants.ts | 1 + src/plugins/bfetch/common/index.ts | 2 +- src/plugins/bfetch/public/index.ts | 2 + src/plugins/bfetch/server/ui_settings.ts | 16 ++- .../search_interceptor/search_interceptor.ts | 67 ++++++++--- .../server/collectors/management/schema.ts | 4 + .../server/collectors/management/types.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 6 + .../search_examples/search_example.ts | 112 +++++++++++------- 9 files changed, 146 insertions(+), 65 deletions(-) diff --git a/src/plugins/bfetch/common/constants.ts b/src/plugins/bfetch/common/constants.ts index bc72ad244d9be..89d0d7c492d86 100644 --- a/src/plugins/bfetch/common/constants.ts +++ b/src/plugins/bfetch/common/constants.ts @@ -7,3 +7,4 @@ */ export const DISABLE_BFETCH_COMPRESSION = 'bfetch:disableCompression'; +export const DISABLE_BFETCH = 'bfetch:disable'; diff --git a/src/plugins/bfetch/common/index.ts b/src/plugins/bfetch/common/index.ts index 26a1734ec759c..491cabd03bf68 100644 --- a/src/plugins/bfetch/common/index.ts +++ b/src/plugins/bfetch/common/index.ts @@ -11,4 +11,4 @@ export type { StreamingResponseHandler } from './streaming'; export type { ItemBufferParams, TimedItemBufferParams, BatchedFunctionParams } from './buffer'; export { ItemBuffer, TimedItemBuffer, createBatchedFunction } from './buffer'; export type { ErrorLike, BatchRequestData, BatchResponseItem, BatchItemWrapper } from './batch'; -export { DISABLE_BFETCH_COMPRESSION } from './constants'; +export { DISABLE_BFETCH_COMPRESSION, DISABLE_BFETCH } from './constants'; diff --git a/src/plugins/bfetch/public/index.ts b/src/plugins/bfetch/public/index.ts index 6ab5480327538..bba7844a3265b 100644 --- a/src/plugins/bfetch/public/index.ts +++ b/src/plugins/bfetch/public/index.ts @@ -14,6 +14,8 @@ export { split } from './streaming'; export type { BatchedFunc } from './batching/types'; +export { DISABLE_BFETCH } from '../common/constants'; + export function plugin(initializerContext: PluginInitializerContext) { return new BfetchPublicPlugin(initializerContext); } diff --git a/src/plugins/bfetch/server/ui_settings.ts b/src/plugins/bfetch/server/ui_settings.ts index cf7b13a9af182..95854b9604dd3 100644 --- a/src/plugins/bfetch/server/ui_settings.ts +++ b/src/plugins/bfetch/server/ui_settings.ts @@ -9,13 +9,25 @@ import { i18n } from '@kbn/i18n'; import { UiSettingsParams } from 'src/core/server'; import { schema } from '@kbn/config-schema'; -import { DISABLE_BFETCH_COMPRESSION } from '../common'; +import { DISABLE_BFETCH_COMPRESSION, DISABLE_BFETCH } from '../common'; export function getUiSettings(): Record> { return { + [DISABLE_BFETCH]: { + name: i18n.translate('bfetch.disableBfetch', { + defaultMessage: 'Disable request batching', + }), + value: false, + description: i18n.translate('bfetch.disableBfetchDesc', { + defaultMessage: + 'Disables requests batching. This increases number of HTTP requests from Kibana, but allows to debug requests individually.', + }), + schema: schema.boolean(), + category: [], + }, [DISABLE_BFETCH_COMPRESSION]: { name: i18n.translate('bfetch.disableBfetchCompression', { - defaultMessage: 'Disable Batch Compression', + defaultMessage: 'Disable batch compression', }), value: false, description: i18n.translate('bfetch.disableBfetchCompressionDesc', { diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts index 8c7bfe68fd54b..7dc1ce6dee078 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -21,9 +21,15 @@ import { tap, } from 'rxjs/operators'; import { PublicMethodsOf } from '@kbn/utility-types'; -import { CoreSetup, CoreStart, ThemeServiceSetup, ToastsSetup } from 'kibana/public'; +import { + CoreSetup, + CoreStart, + IHttpFetchError, + ThemeServiceSetup, + ToastsSetup, +} from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public'; +import { BatchedFunc, BfetchPublicSetup, DISABLE_BFETCH } from '../../../../bfetch/public'; import { ENHANCED_ES_SEARCH_STRATEGY, IAsyncSearchOptions, @@ -67,8 +73,9 @@ const MAX_CACHE_ITEMS = 50; const MAX_CACHE_SIZE_MB = 10; export class SearchInterceptor { - private uiSettingsSub: Subscription; + private uiSettingsSubs: Subscription[] = []; private searchTimeout: number; + private bFetchDisabled: boolean; private readonly responseCache: SearchResponseCache = new SearchResponseCache( MAX_CACHE_ITEMS, MAX_CACHE_SIZE_MB @@ -106,17 +113,21 @@ export class SearchInterceptor { }); this.searchTimeout = deps.uiSettings.get(UI_SETTINGS.SEARCH_TIMEOUT); + this.bFetchDisabled = deps.uiSettings.get(DISABLE_BFETCH); - this.uiSettingsSub = deps.uiSettings - .get$(UI_SETTINGS.SEARCH_TIMEOUT) - .subscribe((timeout: number) => { + this.uiSettingsSubs.push( + deps.uiSettings.get$(UI_SETTINGS.SEARCH_TIMEOUT).subscribe((timeout: number) => { this.searchTimeout = timeout; - }); + }), + deps.uiSettings.get$(DISABLE_BFETCH).subscribe((bFetchDisabled: boolean) => { + this.bFetchDisabled = bFetchDisabled; + }) + ); } public stop() { this.responseCache.clear(); - this.uiSettingsSub.unsubscribe(); + this.uiSettingsSubs.forEach((s) => s.unsubscribe()); } /* @@ -266,13 +277,34 @@ export class SearchInterceptor { options?: ISearchOptions ): Promise { const { abortSignal } = options || {}; - return this.batchedFetch( - { - request, - options: this.getSerializableOptions(options), - }, - abortSignal - ); + + if (this.bFetchDisabled) { + const { executionContext, strategy, ...searchOptions } = this.getSerializableOptions(options); + return this.deps.http + .post(`/internal/search/${strategy}${request.id ? `/${request.id}` : ''}`, { + signal: abortSignal, + context: executionContext, + body: JSON.stringify({ + ...request, + ...searchOptions, + }), + }) + .catch((e: IHttpFetchError) => { + if (e?.body) { + throw e.body; + } else { + throw e; + } + }) as Promise; + } else { + return this.batchedFetch( + { + request, + options: this.getSerializableOptions(options), + }, + abortSignal + ); + } } /** @@ -319,9 +351,12 @@ export class SearchInterceptor { */ public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { const searchOptions = { - strategy: ENHANCED_ES_SEARCH_STRATEGY, ...options, }; + if (!searchOptions.strategy) { + searchOptions.strategy = ENHANCED_ES_SEARCH_STRATEGY; + } + const { sessionId, abortSignal } = searchOptions; return this.createRequestHash$(request, searchOptions).pipe( diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 27e44cba1094f..bde8a10e4dd2e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -392,6 +392,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'bfetch:disable': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'visualization:visualize:legacyPieChartsLibrary': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 8776bad89f8a6..47656c568bf4c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -23,6 +23,7 @@ export interface UsageStats { /** * non-sensitive settings */ + 'bfetch:disable': boolean; 'bfetch:disableCompression': boolean; 'autocomplete:useTimeRange': boolean; 'autocomplete:valueSuggestionMethod': string; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 9c2c71898dee7..3269452ca8cc3 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7736,6 +7736,12 @@ "description": "Non-default value of setting." } }, + "bfetch:disable": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "visualization:visualize:legacyPieChartsLibrary": { "type": "boolean", "_meta": { diff --git a/x-pack/test/examples/search_examples/search_example.ts b/x-pack/test/examples/search_examples/search_example.ts index ac0a9e47fa2f1..f78b2ffbdf545 100644 --- a/x-pack/test/examples/search_examples/search_example.ts +++ b/x-pack/test/examples/search_examples/search_example.ts @@ -17,62 +17,82 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const toasts = getService('toasts'); describe('Search example', () => { - const appId = 'searchExamples'; - - before(async function () { - await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); - await comboBox.setCustom('dataViewSelector', 'logstash-*'); - await comboBox.set('searchBucketField', 'geo.src'); - await comboBox.set('searchMetricField', 'memory'); - await PageObjects.timePicker.setAbsoluteRange( - 'Mar 1, 2015 @ 00:00:00.000', - 'Nov 1, 2015 @ 00:00:00.000' - ); + describe('with bfetch', () => { + testSearchExample(); }); - beforeEach(async () => { - await toasts.dismissAllToasts(); - await retry.waitFor('toasts gone', async () => { - return (await toasts.getToastCount()) === 0; + describe('no bfetch', () => { + const kibanaServer = getService('kibanaServer'); + before(async () => { + await kibanaServer.uiSettings.replace({ + 'bfetch:disable': true, + }); }); + after(async () => { + await kibanaServer.uiSettings.unset('bfetch:disable'); + }); + + testSearchExample(); }); - it('should have an other bucket', async () => { - await testSubjects.click('searchSourceWithOther'); - await testSubjects.click('responseTab'); - const codeBlock = await testSubjects.find('responseCodeBlock'); - await retry.waitFor('get code block', async () => { - const visibleText = await codeBlock.getVisibleText(); - const parsedResponse = JSON.parse(visibleText); - const buckets = parsedResponse.aggregations[1].buckets; - return ( - buckets.length === 3 && buckets[2].key === '__other__' && buckets[2].doc_count === 9039 + const appId = 'searchExamples'; + + function testSearchExample() { + before(async function () { + await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); + await comboBox.setCustom('dataViewSelector', 'logstash-*'); + await comboBox.set('searchBucketField', 'geo.src'); + await comboBox.set('searchMetricField', 'memory'); + await PageObjects.timePicker.setAbsoluteRange( + 'Mar 1, 2015 @ 00:00:00.000', + 'Nov 1, 2015 @ 00:00:00.000' ); }); - }); - it('should not have an other bucket', async () => { - await testSubjects.click('searchSourceWithoutOther'); - await testSubjects.click('responseTab'); - const codeBlock = await testSubjects.find('responseCodeBlock'); - await retry.waitFor('get code block', async () => { - const visibleText = await codeBlock.getVisibleText(); - const parsedResponse = JSON.parse(visibleText); - const buckets = parsedResponse.aggregations[1].buckets; - return buckets.length === 2; + beforeEach(async () => { + await toasts.dismissAllToasts(); + await retry.waitFor('toasts gone', async () => { + return (await toasts.getToastCount()) === 0; + }); }); - }); - it('should handle warnings', async () => { - await testSubjects.click('searchWithWarning'); - await retry.waitFor('', async () => { - const toastCount = await toasts.getToastCount(); - return toastCount > 1; + it('should have an other bucket', async () => { + await testSubjects.click('searchSourceWithOther'); + await testSubjects.click('responseTab'); + const codeBlock = await testSubjects.find('responseCodeBlock'); + await retry.waitFor('get code block', async () => { + const visibleText = await codeBlock.getVisibleText(); + const parsedResponse = JSON.parse(visibleText); + const buckets = parsedResponse.aggregations[1].buckets; + return ( + buckets.length === 3 && buckets[2].key === '__other__' && buckets[2].doc_count === 9039 + ); + }); }); - const warningToast = await toasts.getToastElement(2); - const textEl = await warningToast.findByClassName('euiToastBody'); - const text: string = await textEl.getVisibleText(); - expect(text).to.contain('Watch out!'); - }); + + it('should not have an other bucket', async () => { + await testSubjects.click('searchSourceWithoutOther'); + await testSubjects.click('responseTab'); + const codeBlock = await testSubjects.find('responseCodeBlock'); + await retry.waitFor('get code block', async () => { + const visibleText = await codeBlock.getVisibleText(); + const parsedResponse = JSON.parse(visibleText); + const buckets = parsedResponse.aggregations[1].buckets; + return buckets.length === 2; + }); + }); + + it('should handle warnings', async () => { + await testSubjects.click('searchWithWarning'); + await retry.waitFor('', async () => { + const toastCount = await toasts.getToastCount(); + return toastCount > 1; + }); + const warningToast = await toasts.getToastElement(2); + const textEl = await warningToast.findByClassName('euiToastBody'); + const text: string = await textEl.getVisibleText(); + expect(text).to.contain('Watch out!'); + }); + } }); } From 1f3f1c6fc6f8b4b8036c92161854b10814758708 Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Mon, 31 Jan 2022 12:42:42 +0000 Subject: [PATCH 12/65] [Security Solution][Detections]Adds bulk edit index patterns/tags (#122635) Issues: - https://github.com/elastic/security-team/issues/2076 - https://github.com/elastic/security-team/issues/2077 - https://github.com/elastic/security-team/issues/2079 - https://github.com/elastic/security-team/issues/2081 ## Summary - add ids parameter to Bulk edit API, so now rules can be bulk edited by their ids - adds unit/functional tests for ids parameter - adds Bulk edit UI for tags and index patterns - Bulk edit UI is hidden behind feature enabled `rulesBulkEditEnabled`, which is set to `true` by default. If it will be decided to set it to disable, it can be enabled in Kibana config ```yml xpack.securitySolution.enableExperimental: ['rulesBulkEditEnabled'] ``` - for bulk edit, UI is not getting frozen, instead only single rules and bulk actions are. We also display a warning toast, if action takes too long(after 5s) to inform user 'Unfrozen' UI screen Screenshot 2022-01-26 at 13 26 28 **Notes to frozen UI:** 1. It applied only to bulk edit action for now 2. User still can change pages/apply filters/open rule. We will lose all rules blocking UI in such case. This is a current limitation of the solution. ## TODO - add unit/cypress tests - lift table context to wrap rule details, so loading rules state can be persistent(?) ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Release notes Adds ability to bulk edit rules in rules management/monitoring tables. Available for bulk edit fields: tags and index patterns --- .../security_solution/common/constants.ts | 10 + .../schemas/common/schemas.ts | 6 + .../perform_bulk_action_schema.mock.ts | 2 + .../perform_bulk_action_schema.test.ts | 15 +- .../request/perform_bulk_action_schema.ts | 1 + .../response/perform_bulk_action_schema.ts | 37 ++ .../common/detection_engine/types.ts | 4 + .../common/experimental_features.ts | 1 + .../detection_rules/sorting.spec.ts | 12 +- .../cypress/tasks/alerts_detection_rules.ts | 8 +- .../utility_bar/utility_bar_action.tsx | 95 ++-- .../public/common/hooks/use_app_toasts.ts | 8 +- .../public/common/mock/test_providers.tsx | 28 +- .../containers/detection_engine/rules/api.ts | 11 +- .../rules/rules_table/rules_table_context.tsx | 2 +- .../detection_engine/rules/types.ts | 6 +- .../detection_engine/rules/all/actions.tsx | 82 ++- .../bulk_actions/bulk_edit_confirmation.tsx | 76 +++ .../all/bulk_actions/bulk_edit_flyout.tsx | 45 ++ .../forms/bulk_edit_form_wrapper.tsx | 76 +++ .../forms/index_patterns_form.tsx | 158 ++++++ .../all/bulk_actions/forms/tags_form.tsx | 148 +++++ .../all/bulk_actions/use_bulk_actions.tsx | 515 ++++++++++++++++++ .../bulk_actions/use_bulk_edit_form_flyout.ts | 53 ++ .../bulk_actions/use_custom_rules_count.ts | 50 ++ .../rules/all/exceptions/exceptions_table.tsx | 2 +- .../detection_engine/rules/all/helpers.ts | 12 +- .../rules_table_filters.test.tsx | 6 + .../rules_table_filters.tsx | 8 +- .../rules/all/rules_tables.tsx | 83 ++- .../rules/all/use_bulk_actions.tsx | 266 --------- .../rules/all/use_columns.tsx | 5 +- .../rules/all/utility_bar.test.tsx | 26 +- .../rules/all/utility_bar.tsx | 45 +- .../detection_engine/rules/translations.ts | 313 ++++++++++- .../rules/perform_bulk_action_route.test.ts | 91 ++++ .../routes/rules/perform_bulk_action_route.ts | 139 ++++- .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - .../tests/perform_bulk_action.ts | 33 ++ 40 files changed, 2041 insertions(+), 449 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/response/perform_bulk_action_schema.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/bulk_edit_form_wrapper.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/index_patterns_form.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/tags_form.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_edit_form_flyout.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_custom_rules_count.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_bulk_actions.tsx diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 12df1874754f0..ecd5889c3485b 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -411,3 +411,13 @@ export const WARNING_TRANSFORM_STATES = new Set([ export const MAX_RULES_TO_UPDATE_IN_PARALLEL = 50; export const LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX = `${APP_ID}:limitedConcurrency`; + +/** + * Max number of rules to display on UI in table, max number of rules that can be edited in a single bulk edit API request + * We limit number of rules in bulk edit API, because rulesClient doesn't support bulkGet of rules by ids. + * Given this limitation, current implementation fetches each rule separately through rulesClient.resolve method. + * As max number of rules displayed on a page is 100, max 100 rules can be bulk edited by passing their ids to API. + * We decided add this limit(number of ids less than 100) in bulk edit API as well, to prevent a huge number of single rule fetches + */ +export const RULES_TABLE_MAX_PAGE_SIZE = 100; +export const RULES_TABLE_PAGE_SIZE_OPTIONS = [5, 10, 20, 50, RULES_TABLE_MAX_PAGE_SIZE]; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index f063d14822c84..9765f890e7474 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -442,6 +442,8 @@ const bulkActionEditPayloadTags = t.type({ value: tags, }); +export type BulkActionEditPayloadTags = t.TypeOf; + const bulkActionEditPayloadIndexPatterns = t.type({ type: t.union([ t.literal(BulkActionEditType.add_index_patterns), @@ -451,6 +453,10 @@ const bulkActionEditPayloadIndexPatterns = t.type({ value: index, }); +export type BulkActionEditPayloadIndexPatterns = t.TypeOf< + typeof bulkActionEditPayloadIndexPatterns +>; + const bulkActionEditPayloadTimeline = t.type({ type: t.literal(BulkActionEditType.set_timeline), value: t.type({ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts index b6c241dfd15d2..4c6bdef3a4e80 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts @@ -10,11 +10,13 @@ import { PerformBulkActionSchema } from './perform_bulk_action_schema'; export const getPerformBulkActionSchemaMock = (): PerformBulkActionSchema => ({ query: '', + ids: undefined, action: BulkAction.disable, }); export const getPerformBulkActionEditSchemaMock = (): PerformBulkActionSchema => ({ query: '', + ids: undefined, action: BulkAction.edit, [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['tag1'] }], }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts index 855b7ea506d81..35083052141a4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts @@ -61,11 +61,22 @@ describe('perform_bulk_action_schema', () => { const payload = { query: 'name: test', action: BulkAction.enable, - ids: ['id'], + mock: ['id'], }; const message = retrieveValidationMessage(payload); - expect(getPaths(left(message.errors))).toEqual(['invalid keys "ids,["id"]"']); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "mock,["id"]"']); + expect(message.schema).toEqual({}); + }); + + test('invalid request: wrong type for ids', () => { + const payload = { + ids: 'mock', + action: BulkAction.enable, + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "mock" supplied to "ids"']); expect(message.schema).toEqual({}); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts index 02de2f845b85d..167ed8efec7a9 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts @@ -14,6 +14,7 @@ export const performBulkActionSchema = t.intersection([ query: queryOrUndefined, }) ), + t.exact(t.partial({ ids: t.array(t.string) })), t.union([ t.exact( t.type({ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/perform_bulk_action_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/perform_bulk_action_schema.ts new file mode 100644 index 0000000000000..2bc3ea8448ee9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/perform_bulk_action_schema.ts @@ -0,0 +1,37 @@ +/* + * 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 * as t from 'io-ts'; +import { PositiveInteger, PositiveIntegerGreaterThanZero } from '@kbn/securitysolution-io-ts-types'; + +const rule = t.type({ + id: t.string, + name: t.union([t.string, t.undefined]), +}); + +const error = t.type({ + status_code: PositiveIntegerGreaterThanZero, + message: t.string, + rules: t.array(rule), +}); + +export const bulkActionPartialErrorResponseSchema = t.exact( + t.type({ + status_code: PositiveIntegerGreaterThanZero, + message: t.string, + attributes: t.type({ + errors: t.array(error), + rules: t.type({ + failed: PositiveInteger, + succeeded: PositiveInteger, + total: PositiveInteger, + }), + }), + }) +); + +export type BulkActionPartialErrorResponse = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/types.ts b/x-pack/plugins/security_solution/common/detection_engine/types.ts index e7b8cca8d5a97..eca1ee10bc6d8 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/types.ts @@ -54,3 +54,7 @@ export interface EqlSearchResponse { events?: Array>; }; } + +export interface HTTPError extends Error { + body?: unknown; +} diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 3f19b888d3901..a3a3c3a23acf0 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -22,6 +22,7 @@ export const allowedExperimentalValues = Object.freeze({ riskyHostsEnabled: false, securityRulesCancelEnabled: false, pendingActionResponsesWithAck: true, + rulesBulkEditEnabled: true, policyListEnabled: false, }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts index 23a02eb6dd1be..1a8fa2e972f89 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts @@ -17,11 +17,11 @@ import { import { goToManageAlertsDetectionRules, waitForAlertsPanelToBeLoaded } from '../../tasks/alerts'; import { - activateRule, + enableRule, changeRowsPerPageTo, checkAutoRefresh, goToPage, - sortByActivatedRules, + sortByEnabledRules, waitForRulesTableToBeLoaded, waitForRuleToChangeStatus, } from '../../tasks/alerts_detection_rules'; @@ -50,19 +50,19 @@ describe('Alerts detection rules', () => { createCustomRule(getNewThresholdRule(), '4'); }); - it('Sorts by activated rules', () => { + it('Sorts by enabled rules', () => { goToManageAlertsDetectionRules(); waitForRulesTableToBeLoaded(); - activateRule(SECOND_RULE); + enableRule(SECOND_RULE); waitForRuleToChangeStatus(); - activateRule(FOURTH_RULE); + enableRule(FOURTH_RULE); waitForRuleToChangeStatus(); cy.get(RULE_SWITCH).eq(SECOND_RULE).should('have.attr', 'role', 'switch'); cy.get(RULE_SWITCH).eq(FOURTH_RULE).should('have.attr', 'role', 'switch'); - sortByActivatedRules(); + sortByEnabledRules(); cy.get(RULE_SWITCH).eq(FIRST_RULE).should('have.attr', 'role', 'switch'); cy.get(RULE_SWITCH).eq(SECOND_RULE).should('have.attr', 'role', 'switch'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 377abd53b1af8..03d64ab22608c 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -49,7 +49,7 @@ import { import { ALL_ACTIONS } from '../screens/rule_details'; import { LOADING_INDICATOR } from '../screens/security_header'; -export const activateRule = (rulePosition: number) => { +export const enableRule = (rulePosition: number) => { cy.get(RULE_SWITCH).eq(rulePosition).click({ force: true }); }; @@ -191,9 +191,9 @@ export const confirmRulesDelete = () => { cy.get(RULES_DELETE_CONFIRMATION_MODAL).should('not.exist'); }; -export const sortByActivatedRules = () => { - cy.get(SORT_RULES_BTN).contains('Activated').click({ force: true }); - cy.get(SORT_RULES_BTN).contains('Activated').click({ force: true }); +export const sortByEnabledRules = () => { + cy.get(SORT_RULES_BTN).contains('Enabled').click({ force: true }); + cy.get(SORT_RULES_BTN).contains('Enabled').click({ force: true }); }; export const waitForRulesTableToBeLoaded = () => { diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx index ac30818ff1c3f..faa4733a0bf3e 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx @@ -5,12 +5,24 @@ * 2.0. */ -import { EuiPopover } from '@elastic/eui'; +import { EuiPopover, PanelPaddingSize, EuiButtonEmpty } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; +import styled, { css } from 'styled-components'; import { LinkIcon, LinkIconProps } from '../link_icon'; import { BarAction } from './styles'; +const LoadingButtonEmpty = styled(EuiButtonEmpty)` + ${({ theme }) => css` + &.euiButtonEmpty.euiButtonEmpty--xSmall { + height: ${theme.eui.euiSize}; + .euiButtonEmpty__content { + padding: 0; + } + } + `} +`; + const Popover = React.memo( ({ children, @@ -22,6 +34,7 @@ const Popover = React.memo( disabled, ownFocus, dataTestSubj, + popoverPanelPaddingSize, }) => { const [popoverState, setPopoverState] = useState(false); @@ -30,6 +43,7 @@ const Popover = React.memo( return ( void) => React.ReactNode; + popoverPanelPaddingSize?: PanelPaddingSize; dataTestSubj: string; ownFocus?: boolean; + inProgress?: boolean; } export const UtilityBarAction = React.memo( @@ -74,37 +90,52 @@ export const UtilityBarAction = React.memo( ownFocus, onClick, popoverContent, - }) => ( - - {popoverContent ? ( - - {children} - - ) : ( - - {children} - - )} - - ) + popoverPanelPaddingSize, + inProgress, + }) => { + if (inProgress) { + return ( + + + {children} + + + ); + } + + return ( + + {popoverContent ? ( + + {children} + + ) : ( + + {children} + + )} + + ); + } ); UtilityBarAction.displayName = 'UtilityBarAction'; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts index 0c2721e6ad416..b0719960ff2c8 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useMemo } from 'react'; import { isString } from 'lodash/fp'; import { AppError, @@ -44,7 +44,11 @@ export const useAppToasts = (): UseAppToasts => { }, [addError] ); - return { api: toasts, addError: _addError, addSuccess, addWarning }; + + return useMemo( + () => ({ api: toasts, addError: _addError, addSuccess, addWarning }), + [_addError, addSuccess, addWarning, toasts] + ); }; /** diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 1909388aea27a..f183198e6f5c5 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -15,6 +15,7 @@ import { Store } from 'redux'; import { BehaviorSubject } from 'rxjs'; import { ThemeProvider } from 'styled-components'; import { Capabilities } from 'src/core/public'; +import { QueryClient, QueryClientProvider } from 'react-query'; import { createStore, State } from '../store'; import { mockGlobalState } from './global_state'; @@ -50,17 +51,22 @@ export const TestProvidersComponent: React.FC = ({ children, store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage), onDragEnd = jest.fn(), -}) => ( - - - - ({ eui: euiDarkVars, darkMode: true })}> - {children} - - - - -); +}) => { + const queryClient = new QueryClient(); + return ( + + + + ({ eui: euiDarkVars, darkMode: true })}> + + {children} + + + + + + ); +}; /** * A utility for wrapping children in the providers required to run most tests diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index ad51ec009acbf..c765db6eaecd8 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -255,6 +255,8 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise({ action, query, + edit, + ids, }: BulkActionProps): Promise> => KibanaServices.get().http.fetch>(DETECTION_ENGINE_RULES_BULK_ACTION, { method: 'POST', - body: JSON.stringify({ action, query }), + body: JSON.stringify({ + action, + ...(edit ? { edit } : {}), + ...(ids ? { ids } : {}), + ...(query !== undefined ? { query } : {}), + }), }); /** diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_context.tsx index 41aee63ea5c65..3f91387f52d07 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_context.tsx @@ -168,7 +168,7 @@ export const RulesTableContextProvider = ({ const isActionInProgress = useMemo(() => { if (loadingRules.ids.length > 0) { - return !(loadingRules.action === 'disable' || loadingRules.action === 'enable'); + return !['disable', 'enable', 'edit'].includes(loadingRules.action ?? ''); } return false; }, [loadingRules.action, loadingRules.ids.length]); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 009a95fe31367..de2cc3c7d93d7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -30,8 +30,10 @@ import { timestamp_override, threshold, BulkAction, + BulkActionEditPayload, ruleExecutionSummary, } from '../../../../../common/detection_engine/schemas/common'; + import { CreateRulesSchema, PatchRulesSchema, @@ -230,7 +232,9 @@ export interface DuplicateRulesProps { export interface BulkActionProps { action: Action; - query: string; + query?: string; + ids?: string[]; + edit?: BulkActionEditPayload[]; } export interface BulkActionResult { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx index 93bc806e7ae0d..024b3aba613af 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx @@ -6,9 +6,14 @@ */ import { Dispatch } from 'react'; -import { NavigateToAppOptions } from '../../../../../../../../../src/core/public'; +import type { NavigateToAppOptions } from '../../../../../../../../../src/core/public'; + +import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts'; import { APP_UI_ID } from '../../../../../../common/constants'; -import { BulkAction } from '../../../../../../common/detection_engine/schemas/common/schemas'; +import { + BulkAction, + BulkActionEditPayload, +} from '../../../../../../common/detection_engine/schemas/common/schemas'; import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; import { SecurityPageName } from '../../../../../app/types'; import { getEditRuleUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; @@ -31,7 +36,7 @@ import { import { RulesTableActions } from '../../../../containers/detection_engine/rules/rules_table/rules_table_context'; import { transformOutput } from '../../../../containers/detection_engine/rules/transforms'; import * as i18n from '../translations'; -import { bucketRulesResponse, getExportedRulesCount } from './helpers'; +import { bucketRulesResponse, getExportedRulesCounts } from './helpers'; export const editRuleAction = ( ruleId: string, @@ -84,9 +89,9 @@ export const exportRulesAction = async ( const blob = await exportRules({ ids: exportRuleId }); downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); - const exportedRulesCount = await getExportedRulesCount(blob); + const { exported } = await getExportedRulesCounts(blob); displaySuccessToast( - i18n.SUCCESSFULLY_EXPORTED_RULES(exportedRulesCount, exportRuleId.length), + i18n.SUCCESSFULLY_EXPORTED_RULES(exported, exportRuleId.length), dispatchToaster ); } catch (e) { @@ -171,32 +176,67 @@ export const enableRulesAction = async ( } }; -export const rulesBulkActionByQuery = async ( - visibleRuleIds: string[], - selectedItemsCount: number, - query: string, - action: BulkAction, - dispatchToaster: Dispatch, - setLoadingRules: RulesTableActions['setLoadingRules'] -) => { +interface ExecuteRulesBulkActionArgs { + visibleRuleIds: string[]; + action: BulkAction; + toasts: UseAppToasts; + search: { query: string } | { ids: string[] }; + payload?: { edit?: BulkActionEditPayload[] }; + onSuccess?: (arg: { rulesCount: number }) => void; + onError?: (error: Error) => void; + setLoadingRules: RulesTableActions['setLoadingRules']; +} + +const executeRulesBulkAction = async ({ + visibleRuleIds, + action, + setLoadingRules, + toasts, + search, + payload, + onSuccess, + onError, +}: ExecuteRulesBulkActionArgs) => { try { setLoadingRules({ ids: visibleRuleIds, action }); if (action === BulkAction.export) { - const blob = await performBulkAction({ query, action }); + const blob = await performBulkAction({ ...search, action }); downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); + const { exported, total } = await getExportedRulesCounts(blob); - const exportedRulesCount = await getExportedRulesCount(blob); - displaySuccessToast( - i18n.SUCCESSFULLY_EXPORTED_RULES(exportedRulesCount, selectedItemsCount), - dispatchToaster - ); + toasts.addSuccess(i18n.SUCCESSFULLY_EXPORTED_RULES(exported, total)); } else { - await performBulkAction({ query, action }); + const response = await performBulkAction({ ...search, action, edit: payload?.edit }); + + onSuccess?.({ rulesCount: response.rules_count }); } } catch (e) { - displayErrorToast(i18n.BULK_ACTION_FAILED, [e.message], dispatchToaster); + if (onError) { + onError(e); + } else { + toasts.addError(e, { title: i18n.BULK_ACTION_FAILED }); + } } finally { setLoadingRules({ ids: [], action: null }); } }; + +export const initRulesBulkAction = (params: Omit) => { + const byQuery = (query: string) => + executeRulesBulkAction({ + ...params, + search: { query }, + }); + + const byIds = (ids: string[]) => + executeRulesBulkAction({ + ...params, + search: { ids }, + }); + + return { + byQuery, + byIds, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx new file mode 100644 index 0000000000000..445bd33860be2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx @@ -0,0 +1,76 @@ +/* + * 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, { useEffect } from 'react'; +import { EuiConfirmModal } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import * as i18n from '../../translations'; + +interface BulkEditConfirmationProps { + customRulesCount: number; + elasticRulesCount: number; + onCancel: () => void; + onConfirm: () => void; +} +const BulkEditConfirmationComponent = ({ + onCancel, + onConfirm, + customRulesCount, + elasticRulesCount, +}: BulkEditConfirmationProps) => { + useEffect(() => { + if (elasticRulesCount === 0) { + setTimeout(onConfirm, 0); + } + }, [elasticRulesCount, onConfirm]); + + // proceed straight to edit flyout if there is no Elastic rules + if (elasticRulesCount === 0) { + return null; + } + + if (customRulesCount === 0) { + return ( + + + + ); + } + + return ( + + + + ); +}; + +export const BulkEditConfirmation = React.memo(BulkEditConfirmationComponent); + +BulkEditConfirmation.displayName = 'BulkEditConfirmation'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_flyout.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_flyout.tsx new file mode 100644 index 0000000000000..c4e7005f8e71a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_flyout.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 from 'react'; + +import { + BulkActionEditType, + BulkActionEditPayload, +} from '../../../../../../../common/detection_engine/schemas/common/schemas'; + +import { IndexPatternsForm } from './forms/index_patterns_form'; +import { TagsForm } from './forms/tags_form'; + +interface BulkEditFlyoutProps { + onClose: () => void; + onConfirm: (bulkactionEditPayload: BulkActionEditPayload) => void; + editAction: BulkActionEditType; + rulesCount: number; + tags: string[]; +} + +const BulkEditFlyoutComponent = ({ editAction, tags, ...props }: BulkEditFlyoutProps) => { + switch (editAction) { + case BulkActionEditType.add_index_patterns: + case BulkActionEditType.delete_index_patterns: + case BulkActionEditType.set_index_patterns: + return ; + + case BulkActionEditType.add_tags: + case BulkActionEditType.delete_tags: + case BulkActionEditType.set_tags: + return ; + + default: + return null; + } +}; + +export const BulkEditFlyout = React.memo(BulkEditFlyoutComponent); + +BulkEditFlyout.displayName = 'BulkEditFlyout'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/bulk_edit_form_wrapper.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/bulk_edit_form_wrapper.tsx new file mode 100644 index 0000000000000..c6d60be416408 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/bulk_edit_form_wrapper.tsx @@ -0,0 +1,76 @@ +/* + * 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'; +import { + useGeneratedHtmlId, + EuiFlyout, + EuiFlyoutFooter, + EuiFlexGroup, + EuiButtonEmpty, + EuiFlexItem, + EuiButton, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, +} from '@elastic/eui'; + +import { Form, FormHook } from '../../../../../../../shared_imports'; + +import * as i18n from '../../../translations'; + +interface BulkEditFormWrapperProps { + onClose: () => void; + onSubmit: () => void; + title: string; + form: FormHook; + children: React.ReactNode; +} + +const BulkEditFormWrapperComponent: FC = ({ + form, + onClose, + onSubmit, + children, + title, +}) => { + const simpleFlyoutTitleId = useGeneratedHtmlId({ + prefix: 'BulkEditForm', + }); + + const { isValid } = form; + return ( + + + +

{title}

+
+
+ +
{children}
+
+ + + + + {i18n.BULK_EDIT_FLYOUT_FORM_CLOSE} + + + + + {i18n.BULK_EDIT_FLYOUT_FORM_SAVE} + + + + +
+ ); +}; + +export const BulkEditFormWrapper = React.memo(BulkEditFormWrapperComponent); + +BulkEditFormWrapper.displayName = 'BulkEditFormWrapper'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/index_patterns_form.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/index_patterns_form.tsx new file mode 100644 index 0000000000000..d25ea6e661180 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/index_patterns_form.tsx @@ -0,0 +1,158 @@ +/* + * 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 { EuiFormRow, EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import * as i18n from '../../../translations'; + +import { DEFAULT_INDEX_KEY } from '../../../../../../../../common/constants'; +import { useKibana } from '../../../../../../../common/lib/kibana'; + +import { + BulkActionEditType, + BulkActionEditPayload, +} from '../../../../../../../../common/detection_engine/schemas/common/schemas'; + +import { + Field, + getUseField, + useFormData, + useForm, + FIELD_TYPES, + fieldValidators, + FormSchema, +} from '../../../../../../../shared_imports'; + +import { BulkEditFormWrapper } from './bulk_edit_form_wrapper'; +const CommonUseField = getUseField({ component: Field }); + +type IndexPatternsEditActions = + | BulkActionEditType.add_index_patterns + | BulkActionEditType.delete_index_patterns + | BulkActionEditType.set_index_patterns; + +interface IndexPatternsFormData { + index: string[]; + overwrite: boolean; +} + +const schema: FormSchema = { + index: { + fieldsToValidateOnChange: ['index'], + type: FIELD_TYPES.COMBO_BOX, + validations: [ + { + validator: fieldValidators.emptyField( + i18n.BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_REQUIRED_ERROR + ), + }, + ], + }, + overwrite: { + type: FIELD_TYPES.CHECKBOX, + label: i18n.BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_OVERWRITE_LABEL, + }, +}; + +const initialFormData: IndexPatternsFormData = { index: [], overwrite: false }; + +const getFormConfig = (editAction: IndexPatternsEditActions) => + editAction === BulkActionEditType.add_index_patterns + ? { + indexLabel: i18n.BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_LABEL, + indexHelpText: i18n.BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_HELP_TEXT, + formTitle: i18n.BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_TITLE, + } + : { + indexLabel: i18n.BULK_EDIT_FLYOUT_FORM_DELETE_INDEX_PATTERNS_LABEL, + indexHelpText: i18n.BULK_EDIT_FLYOUT_FORM_DELETE_INDEX_PATTERNS_HELP_TEXT, + formTitle: i18n.BULK_EDIT_FLYOUT_FORM_DELETE_INDEX_PATTERNS_TITLE, + }; + +interface IndexPatternsFormProps { + editAction: IndexPatternsEditActions; + rulesCount: number; + onClose: () => void; + onConfirm: (bulkactionEditPayload: BulkActionEditPayload) => void; +} + +const IndexPatternsFormComponent = ({ + editAction, + rulesCount, + onClose, + onConfirm, +}: IndexPatternsFormProps) => { + const { form } = useForm({ + defaultValue: initialFormData, + schema, + }); + + const { indexHelpText, indexLabel, formTitle } = getFormConfig(editAction); + + const [{ overwrite }] = useFormData({ form, watch: ['overwrite'] }); + const { uiSettings } = useKibana().services; + const defaultPatterns = uiSettings.get(DEFAULT_INDEX_KEY); + + const handleSubmit = async () => { + const { data, isValid } = await form.submit(); + if (!isValid) { + return; + } + + const payload = { + value: data.index, + type: data.overwrite ? BulkActionEditType.set_index_patterns : editAction, + }; + + onConfirm(payload); + }; + + return ( + + ({ label })), + }, + }} + /> + {editAction === BulkActionEditType.add_index_patterns && ( + + )} + {overwrite && ( + + + + + + )} + + ); +}; + +export const IndexPatternsForm = React.memo(IndexPatternsFormComponent); +IndexPatternsForm.displayName = 'IndexPatternsForm'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/tags_form.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/tags_form.tsx new file mode 100644 index 0000000000000..de3aede948425 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/tags_form.tsx @@ -0,0 +1,148 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiFormRow, EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import * as i18n from '../../../translations'; +import { caseInsensitiveSort } from '../../helpers'; +import { + BulkActionEditType, + BulkActionEditPayload, +} from '../../../../../../../../common/detection_engine/schemas/common/schemas'; + +import { + useForm, + Field, + getUseField, + useFormData, + FIELD_TYPES, + fieldValidators, + FormSchema, +} from '../../../../../../../shared_imports'; + +import { BulkEditFormWrapper } from './bulk_edit_form_wrapper'; + +type TagsEditActions = + | BulkActionEditType.add_tags + | BulkActionEditType.delete_tags + | BulkActionEditType.set_tags; + +const CommonUseField = getUseField({ component: Field }); + +export interface TagsFormData { + tags: string[]; + overwrite: boolean; +} + +const schema: FormSchema = { + tags: { + fieldsToValidateOnChange: ['tags'], + type: FIELD_TYPES.COMBO_BOX, + validations: [ + { + validator: fieldValidators.emptyField(i18n.BULK_EDIT_FLYOUT_FORM_TAGS_REQUIRED_ERROR), + }, + ], + }, + overwrite: { + type: FIELD_TYPES.CHECKBOX, + label: i18n.BULK_EDIT_FLYOUT_FORM_ADD_TAGS_OVERWRITE_LABEL, + }, +}; + +const initialFormData: TagsFormData = { tags: [], overwrite: false }; + +const getFormConfig = (editAction: TagsEditActions) => + editAction === BulkActionEditType.add_tags + ? { + tagsLabel: i18n.BULK_EDIT_FLYOUT_FORM_ADD_TAGS_LABEL, + tagsHelpText: i18n.BULK_EDIT_FLYOUT_FORM_ADD_TAGS_HELP_TEXT, + formTitle: i18n.BULK_EDIT_FLYOUT_FORM_ADD_TAGS_TITLE, + } + : { + tagsLabel: i18n.BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_LABEL, + tagsHelpText: i18n.BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_HELP_TEXT, + formTitle: i18n.BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_TITLE, + }; + +interface TagsFormProps { + editAction: TagsEditActions; + rulesCount: number; + onClose: () => void; + onConfirm: (bulkactionEditPayload: BulkActionEditPayload) => void; + tags: string[]; +} + +const TagsFormComponent = ({ editAction, rulesCount, onClose, onConfirm, tags }: TagsFormProps) => { + const { form } = useForm({ + defaultValue: initialFormData, + schema, + }); + const [{ overwrite }] = useFormData({ form, watch: ['overwrite'] }); + const sortedTags = useMemo(() => caseInsensitiveSort(tags), [tags]); + + const { tagsLabel, tagsHelpText, formTitle } = getFormConfig(editAction); + + const handleSubmit = async () => { + const { data, isValid } = await form.submit(); + if (!isValid) { + return; + } + + const payload = { + value: data.tags, + type: data.overwrite ? BulkActionEditType.set_tags : editAction, + }; + + onConfirm(payload); + }; + + return ( + + ({ label })), + }, + }} + /> + {editAction === BulkActionEditType.add_tags ? ( + + ) : null} + {overwrite && ( + + + + + + )} + + ); +}; + +export const TagsForm = React.memo(TagsFormComponent); +TagsForm.displayName = 'TagsForm'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx new file mode 100644 index 0000000000000..f197043a7a291 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx @@ -0,0 +1,515 @@ +/* + * 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. + */ +/* eslint-disable complexity */ + +import React, { useCallback } from 'react'; +import { useQueryClient } from 'react-query'; +import { + EuiTextColor, + EuiContextMenuPanelDescriptor, + EuiFlexGroup, + EuiButton, + EuiFlexItem, +} from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { useIsMounted } from '@kbn/securitysolution-hook-utils'; + +import type { Toast } from '../../../../../../../../../../src/core/public'; +import { mountReactNode } from '../../../../../../../../../../src/core/public/utils'; +import { + BulkAction, + BulkActionEditType, + BulkActionEditPayload, +} from '../../../../../../../common/detection_engine/schemas/common/schemas'; +import { isMlRule } from '../../../../../../../common/machine_learning/helpers'; +import { displayWarningToast, useStateToaster } from '../../../../../../common/components/toasters'; +import { canEditRuleWithActions } from '../../../../../../common/utils/privileges'; +import { useRulesTableContext } from '../../../../../containers/detection_engine/rules/rules_table/rules_table_context'; +import * as detectionI18n from '../../../translations'; +import * as i18n from '../../translations'; +import { + deleteRulesAction, + duplicateRulesAction, + enableRulesAction, + exportRulesAction, + initRulesBulkAction, +} from '../actions'; +import { useHasActionsPrivileges } from '../use_has_actions_privileges'; +import { useHasMlPermissions } from '../use_has_ml_permissions'; +import { getCustomRulesCountFromCache } from './use_custom_rules_count'; +import { useAppToasts } from '../../../../../../common/hooks/use_app_toasts'; +import { useIsExperimentalFeatureEnabled } from '../../../../../../common/hooks/use_experimental_features'; +import { convertRulesFilterToKQL } from '../../../../../containers/detection_engine/rules/utils'; + +import type { FilterOptions } from '../../../../../containers/detection_engine/rules/types'; +import type { BulkActionPartialErrorResponse } from '../../../../../../../common/detection_engine/schemas/response/perform_bulk_action_schema'; +import type { HTTPError } from '../../../../../../../common/detection_engine/types'; + +interface UseBulkActionsArgs { + filterOptions: FilterOptions; + confirmDeletion: () => Promise; + confirmBulkEdit: () => Promise; + completeBulkEditForm: ( + bulkActionEditType: BulkActionEditType + ) => Promise; + reFetchTags: () => void; +} + +export const useBulkActions = ({ + filterOptions, + confirmDeletion, + confirmBulkEdit, + completeBulkEditForm, + reFetchTags, +}: UseBulkActionsArgs) => { + const queryClient = useQueryClient(); + const hasMlPermissions = useHasMlPermissions(); + const rulesTableContext = useRulesTableContext(); + const [, dispatchToaster] = useStateToaster(); + const hasActionsPrivileges = useHasActionsPrivileges(); + const toasts = useAppToasts(); + const isRulesBulkEditEnabled = useIsExperimentalFeatureEnabled('rulesBulkEditEnabled'); + const getIsMounted = useIsMounted(); + const filterQuery = convertRulesFilterToKQL(filterOptions); + + // refetch tags if edit action is related to tags: add_tags/delete_tags/set_tags + const resolveTagsRefetch = useCallback( + async (bulkEditActionType: BulkActionEditType) => { + const isTagsAction = [ + BulkActionEditType.add_tags, + BulkActionEditType.set_tags, + BulkActionEditType.delete_tags, + ].includes(bulkEditActionType); + + return isTagsAction ? reFetchTags() : null; + }, + [reFetchTags] + ); + + const { + state: { isAllSelected, rules, loadingRuleIds, selectedRuleIds }, + actions: { reFetchRules, setLoadingRules, updateRules, setIsRefreshOn }, + } = rulesTableContext; + + return useCallback( + (closePopover: () => void): EuiContextMenuPanelDescriptor[] => { + const selectedRules = rules.filter(({ id }) => selectedRuleIds.includes(id)); + + const containsEnabled = selectedRules.some(({ enabled }) => enabled); + const containsDisabled = selectedRules.some(({ enabled }) => !enabled); + const containsLoading = selectedRuleIds.some((id) => loadingRuleIds.includes(id)); + const containsImmutable = selectedRules.some(({ immutable }) => immutable); + + const missingActionPrivileges = + !hasActionsPrivileges && + selectedRules.some((rule) => !canEditRuleWithActions(rule, hasActionsPrivileges)); + + const handleActivateAction = async () => { + closePopover(); + const deactivatedRules = selectedRules.filter(({ enabled }) => !enabled); + const deactivatedRulesNoML = deactivatedRules.filter(({ type }) => !isMlRule(type)); + + const mlRuleCount = deactivatedRules.length - deactivatedRulesNoML.length; + if (!hasMlPermissions && mlRuleCount > 0) { + displayWarningToast(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount), dispatchToaster); + } + + const ruleIds = hasMlPermissions + ? deactivatedRules.map(({ id }) => id) + : deactivatedRulesNoML.map(({ id }) => id); + + if (isAllSelected) { + const rulesBulkAction = initRulesBulkAction({ + visibleRuleIds: ruleIds, + action: BulkAction.enable, + setLoadingRules, + toasts, + }); + + await rulesBulkAction.byQuery(filterQuery); + await reFetchRules(); + } else { + await enableRulesAction(ruleIds, true, dispatchToaster, setLoadingRules, updateRules); + } + }; + + const handleDeactivateActions = async () => { + closePopover(); + const activatedIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); + if (isAllSelected) { + const rulesBulkAction = initRulesBulkAction({ + visibleRuleIds: activatedIds, + action: BulkAction.disable, + setLoadingRules, + toasts, + }); + + await rulesBulkAction.byQuery(filterQuery); + await reFetchRules(); + } else { + await enableRulesAction( + activatedIds, + false, + dispatchToaster, + setLoadingRules, + updateRules + ); + } + }; + + const handleDuplicateAction = async () => { + closePopover(); + if (isAllSelected) { + const rulesBulkAction = initRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.duplicate, + setLoadingRules, + toasts, + }); + + await rulesBulkAction.byQuery(filterQuery); + } else { + await duplicateRulesAction( + selectedRules, + selectedRuleIds, + dispatchToaster, + setLoadingRules + ); + } + await reFetchRules(); + }; + + const handleDeleteAction = async () => { + closePopover(); + if (isAllSelected) { + if ((await confirmDeletion()) === false) { + // User has cancelled deletion + return; + } + + const rulesBulkAction = initRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.delete, + setLoadingRules, + toasts, + }); + + await rulesBulkAction.byQuery(filterQuery); + } else { + await deleteRulesAction(selectedRuleIds, dispatchToaster, setLoadingRules); + } + await reFetchRules(); + }; + + const handleExportAction = async () => { + closePopover(); + if (isAllSelected) { + const rulesBulkAction = initRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.export, + setLoadingRules, + toasts, + }); + + await rulesBulkAction.byQuery(filterQuery); + } else { + await exportRulesAction( + selectedRules.map((r) => r.rule_id), + dispatchToaster, + setLoadingRules + ); + } + }; + + const handleBulkEdit = (bulkEditActionType: BulkActionEditType) => async () => { + let longTimeWarningToast: Toast; + let isBulkEditFinished = false; + try { + // disabling auto-refresh so user's selected rules won't disappear after table refresh + setIsRefreshOn(false); + closePopover(); + + const customSelectedRuleIds = selectedRules + .filter((rule) => rule.immutable === false) + .map((rule) => rule.id); + + // User has cancelled edit action or there are no custom rules to proceed + if ((await confirmBulkEdit()) === false) { + setIsRefreshOn(true); + return; + } + + const editPayload = await completeBulkEditForm(bulkEditActionType); + if (editPayload == null) { + throw Error('Bulk edit payload is empty'); + } + + const hideWarningToast = () => { + if (longTimeWarningToast) { + toasts.api.remove(longTimeWarningToast); + } + }; + + const customRulesCount = isAllSelected + ? getCustomRulesCountFromCache(queryClient) + : customSelectedRuleIds.length; + + // show warning toast only if bulk edit action exceeds 5s + // if bulkAction already finished, we won't show toast at all (hence flag "isBulkEditFinished") + setTimeout(() => { + if (isBulkEditFinished) { + return; + } + longTimeWarningToast = toasts.addWarning( + { + title: i18n.BULK_EDIT_WARNING_TOAST_TITLE, + text: mountReactNode( + <> +

{i18n.BULK_EDIT_WARNING_TOAST_DESCRIPTION(customRulesCount)}

+ + + + {i18n.BULK_EDIT_WARNING_TOAST_NOTIFY} + + + + + ), + iconType: undefined, + }, + { toastLifeTimeMs: 10 * 60 * 1000 } + ); + }, 5 * 1000); + + const rulesBulkAction = initRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.edit, + setLoadingRules, + toasts, + payload: { edit: [editPayload] }, + onSuccess: ({ rulesCount }) => { + hideWarningToast(); + toasts.addSuccess({ + title: i18n.BULK_EDIT_SUCCESS_TOAST_TITLE, + text: i18n.BULK_EDIT_SUCCESS_TOAST_DESCRIPTION(rulesCount), + iconType: undefined, + }); + }, + onError: (error: HTTPError) => { + hideWarningToast(); + + // if response doesn't have number of failed rules, it means the whole bulk action failed + // and general error toast will be shown. Otherwise - error toast for partial failure + const failedRulesCount = (error?.body as BulkActionPartialErrorResponse)?.attributes + ?.rules?.failed; + + if (isNaN(failedRulesCount)) { + toasts.addError(error, { title: i18n.BULK_ACTION_FAILED }); + } else { + try { + error.stack = JSON.stringify(error.body, null, 2); + toasts.addError(error, { + title: i18n.BULK_EDIT_ERROR_TOAST_TITLE, + toastMessage: i18n.BULK_EDIT_ERROR_TOAST_DESCIRPTION(failedRulesCount), + }); + } catch (e) { + // toast error has failed + } + } + }, + }); + + // only edit custom rules, as elastic rule are immutable + if (isAllSelected) { + const customRulesOnlyFilterQuery = convertRulesFilterToKQL({ + ...filterOptions, + showCustomRules: true, + }); + await rulesBulkAction.byQuery(customRulesOnlyFilterQuery); + } else { + await rulesBulkAction.byIds(customSelectedRuleIds); + } + + isBulkEditFinished = true; + if (getIsMounted()) { + await Promise.allSettled([reFetchRules(), resolveTagsRefetch(bulkEditActionType)]); + } + } catch (e) { + // user has cancelled form or error has occured + } finally { + isBulkEditFinished = true; + if (getIsMounted()) { + setIsRefreshOn(true); + } + } + }; + + const isDeleteDisabled = containsLoading || selectedRuleIds.length === 0; + const isEditDisabled = + missingActionPrivileges || containsLoading || selectedRuleIds.length === 0; + + return [ + { + id: 0, + title: isRulesBulkEditEnabled ? i18n.BULK_ACTION_MENU_TITLE : undefined, + items: [ + { + key: i18n.BULK_ACTION_ENABLE, + name: i18n.BULK_ACTION_ENABLE, + 'data-test-subj': 'activateRuleBulk', + disabled: + missingActionPrivileges || containsLoading || (!containsDisabled && !isAllSelected), + onClick: handleActivateAction, + toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined, + toolTipPosition: 'right', + icon: isRulesBulkEditEnabled ? undefined : 'checkInCircleFilled', + }, + { + key: i18n.BULK_ACTION_DUPLICATE, + name: i18n.BULK_ACTION_DUPLICATE, + 'data-test-subj': 'duplicateRuleBulk', + disabled: isEditDisabled, + onClick: handleDuplicateAction, + toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined, + toolTipPosition: 'right', + icon: isRulesBulkEditEnabled ? undefined : 'crossInACircleFilled', + }, + ...(isRulesBulkEditEnabled + ? [ + { + key: i18n.BULK_ACTION_INDEX_PATTERNS, + name: i18n.BULK_ACTION_INDEX_PATTERNS, + 'data-test-subj': 'indexPatternsBulkEditRule', + disabled: isEditDisabled, + panel: 2, + }, + { + key: i18n.BULK_ACTION_TAGS, + name: i18n.BULK_ACTION_TAGS, + 'data-test-subj': 'tagsBulkEditRule', + disabled: isEditDisabled, + panel: 1, + }, + ] + : []), + { + key: i18n.BULK_ACTION_EXPORT, + name: i18n.BULK_ACTION_EXPORT, + 'data-test-subj': 'exportRuleBulk', + disabled: + (containsImmutable && !isAllSelected) || + containsLoading || + selectedRuleIds.length === 0, + onClick: handleExportAction, + icon: isRulesBulkEditEnabled ? undefined : 'exportAction', + }, + { + key: i18n.BULK_ACTION_DISABLE, + name: i18n.BULK_ACTION_DISABLE, + 'data-test-subj': 'deactivateRuleBulk', + disabled: + missingActionPrivileges || containsLoading || (!containsEnabled && !isAllSelected), + onClick: handleDeactivateActions, + toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined, + toolTipPosition: 'right', + icon: isRulesBulkEditEnabled ? undefined : 'copy', + }, + { + key: i18n.BULK_ACTION_DELETE, + name: ( + + {i18n.BULK_ACTION_DELETE} + + ), + 'data-test-subj': 'deleteRuleBulk', + disabled: isDeleteDisabled, + onClick: handleDeleteAction, + toolTipContent: containsImmutable + ? i18n.BATCH_ACTION_DELETE_SELECTED_IMMUTABLE + : undefined, + toolTipPosition: 'right', + icon: isRulesBulkEditEnabled ? undefined : 'trash', + }, + ], + }, + { + id: 1, + title: i18n.BULK_ACTION_MENU_TITLE, + items: [ + { + key: i18n.BULK_ACTION_ADD_TAGS, + name: i18n.BULK_ACTION_ADD_TAGS, + 'data-test-subj': 'addTagsBulkEditRule', + onClick: handleBulkEdit(BulkActionEditType.add_tags), + disabled: isEditDisabled, + toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined, + toolTipPosition: 'right', + }, + { + key: i18n.BULK_ACTION_DELETE_TAGS, + name: i18n.BULK_ACTION_DELETE_TAGS, + 'data-test-subj': 'deleteTagsBulkEditRule', + onClick: handleBulkEdit(BulkActionEditType.delete_tags), + disabled: isEditDisabled, + toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined, + toolTipPosition: 'right', + }, + ], + }, + { + id: 2, + title: i18n.BULK_ACTION_MENU_TITLE, + items: [ + { + key: i18n.BULK_ACTION_ADD_INDEX_PATTERNS, + name: i18n.BULK_ACTION_ADD_INDEX_PATTERNS, + 'data-test-subj': 'addIndexPatternsBulkEditRule', + onClick: handleBulkEdit(BulkActionEditType.add_index_patterns), + disabled: isEditDisabled, + toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined, + toolTipPosition: 'right', + }, + { + key: i18n.BULK_ACTION_DELETE_INDEX_PATTERNS, + name: i18n.BULK_ACTION_DELETE_INDEX_PATTERNS, + 'data-test-subj': 'deleteIndexPatternsBulkEditRule', + onClick: handleBulkEdit(BulkActionEditType.delete_index_patterns), + disabled: isEditDisabled, + toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined, + toolTipPosition: 'right', + }, + ], + }, + ]; + }, + [ + rules, + selectedRuleIds, + hasActionsPrivileges, + isAllSelected, + loadingRuleIds, + hasMlPermissions, + dispatchToaster, + filterQuery, + setLoadingRules, + reFetchRules, + updateRules, + confirmDeletion, + isRulesBulkEditEnabled, + toasts, + filterOptions, + completeBulkEditForm, + confirmBulkEdit, + resolveTagsRefetch, + setIsRefreshOn, + getIsMounted, + queryClient, + ] + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_edit_form_flyout.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_edit_form_flyout.ts new file mode 100644 index 0000000000000..1f3dad4d50aae --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_edit_form_flyout.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 { useState, useCallback, useRef } from 'react'; +import { useAsyncConfirmation } from '../../../../../containers/detection_engine/rules/rules_table/use_async_confirmation'; + +import { + BulkActionEditType, + BulkActionEditPayload, +} from '../../../../../../../common/detection_engine/schemas/common/schemas'; +import { useBoolState } from '../../../../../../common/hooks/use_bool_state'; + +export const useBulkEditFormFlyout = () => { + const dataFormRef = useRef(null); + const [actionType, setActionType] = useState(); + const [isBulkEditFlyoutVisible, showBulkEditFlyout, hideBulkEditFlyout] = useBoolState(); + + const [confirmForm, onConfirm, onCancel] = useAsyncConfirmation({ + onInit: showBulkEditFlyout, + onFinish: hideBulkEditFlyout, + }); + + const completeBulkEditForm = useCallback( + async (editActionType: BulkActionEditType) => { + setActionType(editActionType); + if ((await confirmForm()) === true) { + return dataFormRef.current; + } else { + throw Error('Form is cancelled'); + } + }, + [confirmForm] + ); + + const handleBulkEditFormConfirm = useCallback( + (data: BulkActionEditPayload) => { + dataFormRef.current = data; + onConfirm(); + }, + [onConfirm] + ); + + return { + bulkEditActionType: actionType, + isBulkEditFlyoutVisible, + handleBulkEditFormConfirm, + handleBulkEditFormCancel: onCancel, + completeBulkEditForm, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_custom_rules_count.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_custom_rules_count.ts new file mode 100644 index 0000000000000..761d7a2a917ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_custom_rules_count.ts @@ -0,0 +1,50 @@ +/* + * 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 { useQuery, QueryClient } from 'react-query'; + +import { fetchRules } from '../../../../../containers/detection_engine/rules/api'; +import type { FilterOptions } from '../../../../../containers/detection_engine/rules/types'; + +const CUSTOM_RULES_COUNT_QUERY_KEY = 'customRulesCount'; +interface CustomRulesCountData { + customRulesCount: number; +} + +export const getCustomRulesCountFromCache = (queryClient: QueryClient) => + queryClient.getQueryData(CUSTOM_RULES_COUNT_QUERY_KEY)?.customRulesCount ?? + 0; + +type UseCustomRulesCount = (arg: { filterOptions: FilterOptions; enabled: boolean }) => { + customRulesCount: number; + isCustomRulesCountLoading: boolean; +}; + +export const useCustomRulesCount: UseCustomRulesCount = ({ filterOptions, enabled }) => { + const { data, isFetching } = useQuery( + [CUSTOM_RULES_COUNT_QUERY_KEY], + async ({ signal }) => { + const res = await fetchRules({ + pagination: { perPage: 1, page: 1 }, + filterOptions: { ...filterOptions, showCustomRules: true }, + signal, + }); + + return { + customRulesCount: res.total, + }; + }, + { + enabled, + } + ); + + return { + customRulesCount: data?.customRulesCount ?? 0, + isCustomRulesCountLoading: isFetching, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index de9da5c293fc1..f7b1127129b3b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -370,7 +370,7 @@ export const ExceptionListsTable = React.memo(() => { ) : ( <> => { +export const getExportedRulesCounts = async (blob: Blob) => { const details = await getExportedRulesDetails(blob); - return details.exported_rules_count; + return { + exported: details.exported_rules_count, + missing: details.missing_rules_count, + total: details.exported_rules_count + details.missing_rules_count, + }; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx index a84a60af51b39..e627ce3815e59 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx @@ -31,6 +31,9 @@ describe('RulesTableFilters', () => { rulesCustomInstalled={null} rulesInstalled={null} currentFilterTags={[]} + tags={[]} + isLoadingTags={false} + reFetchTags={() => ({})} /> ); @@ -51,6 +54,9 @@ describe('RulesTableFilters', () => { rulesCustomInstalled={10} rulesInstalled={9} currentFilterTags={[]} + tags={[]} + isLoadingTags={false} + reFetchTags={() => ({})} /> ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx index a7e1f1b663521..5987cd75d303e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx @@ -19,7 +19,6 @@ import { isEqual } from 'lodash/fp'; import * as i18n from '../../translations'; import { FilterOptions } from '../../../../../containers/detection_engine/rules'; -import { useTags } from '../../../../../containers/detection_engine/rules/use_tags'; import { TagsFilterPopover } from './tags_filter_popover'; interface RulesTableFiltersProps { @@ -27,6 +26,9 @@ interface RulesTableFiltersProps { rulesCustomInstalled: number | null; rulesInstalled: number | null; currentFilterTags: string[]; + tags: string[]; + isLoadingTags: boolean; + reFetchTags: () => void; } /** @@ -40,12 +42,14 @@ const RulesTableFiltersComponent = ({ rulesCustomInstalled, rulesInstalled, currentFilterTags, + tags, + isLoadingTags, + reFetchTags, }: RulesTableFiltersProps) => { const [filter, setFilter] = useState(''); const [selectedTags, setSelectedTags] = useState([]); const [showCustomRules, setShowCustomRules] = useState(false); const [showElasticRules, setShowElasticRules] = useState(false); - const [isLoadingTags, tags, reFetchTags] = useTags(); useEffect(() => { reFetchTags(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index c21680242b818..b1595da0088ba 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable complexity */ + import { EuiBasicTable, EuiConfirmModal, @@ -13,6 +15,8 @@ import { EuiProgress, } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { partition } from 'lodash/fp'; + import { AllRulesTabs } from '.'; import { HeaderSection } from '../../../../../common/components/header_section'; import { Loader } from '../../../../../common/components/loader'; @@ -28,15 +32,20 @@ import { } from '../../../../containers/detection_engine/rules'; import { useRulesTableContext } from '../../../../containers/detection_engine/rules/rules_table/rules_table_context'; import { useAsyncConfirmation } from '../../../../containers/detection_engine/rules/rules_table/use_async_confirmation'; -import { convertRulesFilterToKQL } from '../../../../containers/detection_engine/rules/utils'; import { getPrePackagedRuleStatus } from '../helpers'; import * as i18n from '../translations'; import { EuiBasicTableOnChange } from '../types'; -import { useBulkActions } from './use_bulk_actions'; import { useMonitoringColumns, useRulesColumns } from './use_columns'; import { showRulesTable } from './helpers'; import { RulesTableFilters } from './rules_table_filters/rules_table_filters'; import { AllRulesUtilityBar } from './utility_bar'; +import { RULES_TABLE_PAGE_SIZE_OPTIONS } from '../../../../../../common/constants'; +import { useTags } from '../../../../containers/detection_engine/rules/use_tags'; +import { useCustomRulesCount } from './bulk_actions/use_custom_rules_count'; +import { useBulkEditFormFlyout } from './bulk_actions/use_bulk_edit_form_flyout'; +import { BulkEditConfirmation } from './bulk_actions/bulk_edit_confirmation'; +import { BulkEditFlyout } from './bulk_actions/bulk_edit_flyout'; +import { useBulkActions } from './bulk_actions/use_bulk_actions'; const INITIAL_SORT_FIELD = 'enabled'; @@ -95,6 +104,7 @@ export const RulesTables = React.memo( isRefreshOn, lastUpdated, loadingRuleIds, + loadingRulesAction, pagination, selectedRuleIds, sortingOptions, @@ -117,6 +127,8 @@ export const RulesTables = React.memo( rulesNotUpdated ); + const [isLoadingTags, tags, reFetchTags] = useTags(); + const [isDeleteConfirmationVisible, showDeleteConfirmation, hideDeleteConfirmation] = useBoolState(); @@ -125,13 +137,42 @@ export const RulesTables = React.memo( onFinish: hideDeleteConfirmation, }); + const [isBulkEditConfirmationVisible, showBulkEditonfirmation, hideBulkEditConfirmation] = + useBoolState(); + + const [confirmBulkEdit, handleBulkEditConfirm, handleBulkEditCancel] = useAsyncConfirmation({ + onInit: showBulkEditonfirmation, + onFinish: hideBulkEditConfirmation, + }); + + const { customRulesCount, isCustomRulesCountLoading } = useCustomRulesCount({ + enabled: isBulkEditConfirmationVisible && isAllSelected, + filterOptions, + }); + + const { + bulkEditActionType, + isBulkEditFlyoutVisible, + handleBulkEditFormConfirm, + handleBulkEditFormCancel, + completeBulkEditForm, + } = useBulkEditFormFlyout(); + const selectedItemsCount = isAllSelected ? pagination.total : selectedRuleIds.length; const hasPagination = pagination.total > pagination.perPage; - const getBatchItemsPopoverContent = useBulkActions({ - filterQuery: convertRulesFilterToKQL(filterOptions), + const [selectedElasticRuleIds, selectedCustomRuleIds] = useMemo(() => { + const ruleImmutablityMap = new Map(rules.map((rule) => [rule.id, rule.immutable])); + const predicate = (id: string) => ruleImmutablityMap.get(id); + return partition(predicate, selectedRuleIds); + }, [rules, selectedRuleIds]); + + const getBulkItemsPopoverContent = useBulkActions({ + filterOptions, confirmDeletion, - selectedItemsCount, + confirmBulkEdit, + completeBulkEditForm, + reFetchTags, }); const paginationMemo = useMemo( @@ -139,7 +180,7 @@ export const RulesTables = React.memo( pageIndex: pagination.page - 1, pageSize: pagination.perPage, totalItemCount: pagination.total, - pageSizeOptions: [5, 10, 20, 50, 100], + pageSizeOptions: RULES_TABLE_PAGE_SIZE_OPTIONS, }), [pagination] ); @@ -281,6 +322,9 @@ export const RulesTables = React.memo( rulesCustomInstalled={rulesCustomInstalled} rulesInstalled={rulesInstalled} currentFilterTags={filterOptions.tags} + isLoadingTags={isLoadingTags} + tags={tags} + reFetchTags={reFetchTags} /> )} @@ -308,6 +352,27 @@ export const RulesTables = React.memo(

{i18n.DELETE_CONFIRMATION_BODY}

)} + {isBulkEditConfirmationVisible && !isCustomRulesCountLoading && ( + + )} + {isBulkEditFlyoutVisible && bulkEditActionType !== undefined && ( + + )} {shouldShowRulesTable && ( <> ( hasPagination={hasPagination} paginationTotal={pagination.total ?? 0} numberSelectedItems={selectedItemsCount} - onGetBatchItemsPopoverContent={getBatchItemsPopoverContent} + onGetBulkItemsPopoverContent={getBulkItemsPopoverContent} onRefresh={reFetchRules} isAutoRefreshOn={isRefreshOn} onRefreshSwitch={handleAutoRefreshSwitch} isAllSelected={isAllSelected} onToggleSelectAll={toggleSelectAll} - showBulkActions + isBulkActionInProgress={isCustomRulesCountLoading || loadingRulesAction != null} + hasDisabledActions={loadingRulesAction != null} + hasBulkActions /> Promise; - selectedItemsCount: number; -} - -export const useBulkActions = ({ - filterQuery, - confirmDeletion, - selectedItemsCount, -}: UseBulkActionsArgs) => { - const hasMlPermissions = useHasMlPermissions(); - const rulesTableContext = useRulesTableContext(); - const [, dispatchToaster] = useStateToaster(); - const hasActionsPrivileges = useHasActionsPrivileges(); - - const { - state: { isAllSelected, rules, loadingRuleIds, selectedRuleIds }, - actions: { reFetchRules, setLoadingRules, updateRules }, - } = rulesTableContext; - - return useCallback( - (closePopover: () => void): JSX.Element[] => { - const selectedRules = rules.filter(({ id }) => selectedRuleIds.includes(id)); - - const containsEnabled = selectedRules.some(({ enabled }) => enabled); - const containsDisabled = selectedRules.some(({ enabled }) => !enabled); - const containsLoading = selectedRuleIds.some((id) => loadingRuleIds.includes(id)); - const containsImmutable = selectedRules.some(({ immutable }) => immutable); - - const missingActionPrivileges = - !hasActionsPrivileges && - selectedRules.some((rule) => !canEditRuleWithActions(rule, hasActionsPrivileges)); - - const handleActivateAction = async () => { - closePopover(); - const deactivatedRules = selectedRules.filter(({ enabled }) => !enabled); - const deactivatedRulesNoML = deactivatedRules.filter(({ type }) => !isMlRule(type)); - - const mlRuleCount = deactivatedRules.length - deactivatedRulesNoML.length; - if (!hasMlPermissions && mlRuleCount > 0) { - displayWarningToast(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount), dispatchToaster); - } - - const ruleIds = hasMlPermissions - ? deactivatedRules.map(({ id }) => id) - : deactivatedRulesNoML.map(({ id }) => id); - - if (isAllSelected) { - await rulesBulkActionByQuery( - ruleIds, - selectedItemsCount, - filterQuery, - BulkAction.enable, - dispatchToaster, - setLoadingRules - ); - await reFetchRules(); - } else { - await enableRulesAction(ruleIds, true, dispatchToaster, setLoadingRules, updateRules); - } - }; - - const handleDeactivateActions = async () => { - closePopover(); - const activatedIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); - if (isAllSelected) { - await rulesBulkActionByQuery( - activatedIds, - selectedItemsCount, - filterQuery, - BulkAction.disable, - dispatchToaster, - setLoadingRules - ); - await reFetchRules(); - } else { - await enableRulesAction( - activatedIds, - false, - dispatchToaster, - setLoadingRules, - updateRules - ); - } - }; - - const handleDuplicateAction = async () => { - closePopover(); - if (isAllSelected) { - await rulesBulkActionByQuery( - selectedRuleIds, - selectedItemsCount, - filterQuery, - BulkAction.duplicate, - dispatchToaster, - setLoadingRules - ); - await reFetchRules(); - } else { - await duplicateRulesAction( - selectedRules, - selectedRuleIds, - dispatchToaster, - setLoadingRules - ); - } - await reFetchRules(); - }; - - const handleDeleteAction = async () => { - closePopover(); - if (isAllSelected) { - if ((await confirmDeletion()) === false) { - // User has cancelled deletion - return; - } - - await rulesBulkActionByQuery( - selectedRuleIds, - selectedItemsCount, - filterQuery, - BulkAction.delete, - dispatchToaster, - setLoadingRules - ); - } else { - await deleteRulesAction(selectedRuleIds, dispatchToaster, setLoadingRules); - } - await reFetchRules(); - }; - - const handleExportAction = async () => { - closePopover(); - if (isAllSelected) { - await rulesBulkActionByQuery( - selectedRuleIds, - selectedItemsCount, - filterQuery, - BulkAction.export, - dispatchToaster, - setLoadingRules - ); - } else { - await exportRulesAction( - selectedRules.map((r) => r.rule_id), - dispatchToaster, - setLoadingRules - ); - } - }; - - return [ - - - <>{i18n.BATCH_ACTION_ACTIVATE_SELECTED} - - , - - - <>{i18n.BATCH_ACTION_DEACTIVATE_SELECTED} - - , - - {i18n.BATCH_ACTION_EXPORT_SELECTED} - , - - - - <>{i18n.BATCH_ACTION_DUPLICATE_SELECTED} - - , - - {i18n.BATCH_ACTION_DELETE_SELECTED} - , - ]; - }, - [ - rules, - selectedRuleIds, - hasActionsPrivileges, - isAllSelected, - loadingRuleIds, - hasMlPermissions, - dispatchToaster, - selectedItemsCount, - filterQuery, - setLoadingRules, - reFetchRules, - updateRules, - confirmDeletion, - ] - ); -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx index fa3e02c183516..d7477a792a115 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -58,15 +58,14 @@ const useEnabledColumn = ({ hasPermissions }: ColumnsProps): TableColumn => { const { loadingRulesAction, loadingRuleIds } = useRulesTableContext().state; const loadingIds = useMemo( - () => - loadingRulesAction === 'enable' || loadingRulesAction === 'disable' ? loadingRuleIds : [], + () => (['disable', 'enable', 'edit'].includes(loadingRulesAction ?? '') ? loadingRuleIds : []), [loadingRuleIds, loadingRulesAction] ); return useMemo( () => ({ field: 'enabled', - name: i18n.COLUMN_ACTIVATE, + name: i18n.COLUMN_ENABLE, render: (_, rule: Rule) => ( { onRefresh={jest.fn()} paginationTotal={4} numberSelectedItems={1} - onGetBatchItemsPopoverContent={jest.fn()} + onGetBulkItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} - showBulkActions + hasBulkActions /> ); @@ -40,7 +40,7 @@ describe('AllRules', () => { ); }); - it('does not render total selected and bulk actions when "showBulkActions" is false', () => { + it('does not render total selected and bulk actions when "hasBulkActions" is false', () => { const wrapper = mount( { onRefresh={jest.fn()} paginationTotal={4} numberSelectedItems={1} - onGetBatchItemsPopoverContent={jest.fn()} + onGetBulkItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} - showBulkActions={false} + hasBulkActions={false} /> ); @@ -71,10 +71,10 @@ describe('AllRules', () => { onRefresh={jest.fn()} paginationTotal={4} numberSelectedItems={1} - onGetBatchItemsPopoverContent={jest.fn()} + onGetBulkItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} - showBulkActions + hasBulkActions /> ); @@ -90,10 +90,10 @@ describe('AllRules', () => { onRefresh={jest.fn()} paginationTotal={4} numberSelectedItems={1} - onGetBatchItemsPopoverContent={jest.fn()} + onGetBulkItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} - showBulkActions + hasBulkActions /> ); @@ -110,10 +110,10 @@ describe('AllRules', () => { onRefresh={mockRefresh} paginationTotal={4} numberSelectedItems={1} - onGetBatchItemsPopoverContent={jest.fn()} + onGetBulkItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} - showBulkActions + hasBulkActions /> ); @@ -132,10 +132,10 @@ describe('AllRules', () => { onRefresh={jest.fn()} paginationTotal={4} numberSelectedItems={1} - onGetBatchItemsPopoverContent={jest.fn()} + onGetBulkItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={mockSwitch} - showBulkActions + hasBulkActions /> ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx index 2e53091dc97df..7276415b852b2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -5,7 +5,13 @@ * 2.0. */ -import { EuiContextMenuPanel, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { + EuiContextMenu, + EuiContextMenuPanel, + EuiSwitch, + EuiSwitchEvent, + EuiContextMenuPanelDescriptor, +} from '@elastic/eui'; import React, { useCallback } from 'react'; import { @@ -22,13 +28,15 @@ interface AllRulesUtilityBarProps { isAllSelected?: boolean; isAutoRefreshOn?: boolean; numberSelectedItems: number; - onGetBatchItemsPopoverContent?: (closePopover: () => void) => JSX.Element[]; + onGetBulkItemsPopoverContent?: (closePopover: () => void) => EuiContextMenuPanelDescriptor[]; onRefresh?: () => void; onRefreshSwitch?: (checked: boolean) => void; onToggleSelectAll?: () => void; paginationTotal: number; - showBulkActions: boolean; + hasBulkActions: boolean; hasPagination?: boolean; + isBulkActionInProgress?: boolean; + hasDisabledActions?: boolean; } export const AllRulesUtilityBar = React.memo( @@ -37,23 +45,30 @@ export const AllRulesUtilityBar = React.memo( isAllSelected, isAutoRefreshOn, numberSelectedItems, - onGetBatchItemsPopoverContent, + onGetBulkItemsPopoverContent, onRefresh, onRefreshSwitch, onToggleSelectAll, paginationTotal, - showBulkActions = true, + hasBulkActions = true, hasPagination, + isBulkActionInProgress, + hasDisabledActions, }) => { - const handleGetBatchItemsPopoverContent = useCallback( + const handleGetBuIktemsPopoverContent = useCallback( (closePopover: () => void): JSX.Element | null => { - if (onGetBatchItemsPopoverContent != null) { - return ; + if (onGetBulkItemsPopoverContent != null) { + return ( + + ); } else { return null; } }, - [onGetBatchItemsPopoverContent] + [onGetBulkItemsPopoverContent] ); const handleAutoRefreshSwitch = useCallback( @@ -88,7 +103,7 @@ export const AllRulesUtilityBar = React.memo( - {showBulkActions ? ( + {hasBulkActions ? ( {i18n.SHOWING_RULES(paginationTotal)} @@ -99,7 +114,7 @@ export const AllRulesUtilityBar = React.memo( )} - {showBulkActions ? ( + {hasBulkActions ? ( <> @@ -108,6 +123,7 @@ export const AllRulesUtilityBar = React.memo( {canBulkEdit && onToggleSelectAll && hasPagination && ( ( {canBulkEdit && ( {i18n.BATCH_ACTIONS} )} ( {i18n.REFRESH} +export const BULK_ACTION_DISABLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disableTitle', + { + defaultMessage: 'Disable', + } +); + +export const BULK_ACTION_EXPORT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.exportTitle', + { + defaultMessage: 'Export', + } +); + +export const BULK_ACTION_DUPLICATE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicateTitle', + { + defaultMessage: 'Duplicate', + } +); + +export const BULK_ACTION_DELETE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.deleteTitle', + { + defaultMessage: 'Delete', + } +); + +export const BULK_ACTION_INDEX_PATTERNS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.indexPatternsTitle', + { + defaultMessage: 'Index patterns', + } +); + +export const BULK_ACTION_TAGS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.tagsTitle', + { + defaultMessage: 'Tags', + } +); + +export const BULK_ACTION_ADD_INDEX_PATTERNS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addIndexPatternsTitle', + { + defaultMessage: 'Add index patterns', + } +); + +export const BULK_ACTION_DELETE_INDEX_PATTERNS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.deleteIndexPatternsTitle', + { + defaultMessage: 'Delete index patterns', + } +); + +export const BULK_ACTION_ADD_TAGS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addTagsTitle', + { + defaultMessage: 'Add tags', + } +); + +export const BULK_ACTION_DELETE_TAGS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.deleteTagsTitle', + { + defaultMessage: 'Delete tags', + } +); + +export const BULK_ACTION_MENU_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.contextMenuTitle', + { + defaultMessage: 'Options', + } +); + +export const BULK_EDIT_SUCCESS_TOAST_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastTitle', + { + defaultMessage: 'Rules changes updated', + } +); + +export const BULK_EDIT_SUCCESS_TOAST_DESCRIPTION = (rulesCount: number) => i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.activateSelectedErrorTitle', + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastDescription', { - values: { totalRules }, - defaultMessage: 'Error activating {totalRules, plural, =1 {rule} other {rules}}', + values: { rulesCount }, + defaultMessage: + 'You’ve successfully updated {rulesCount, plural, =1 {# rule} other {# rules}}.', } ); -export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deactivateSelectedTitle', +export const BULK_EDIT_WARNING_TOAST_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastTitle', { - defaultMessage: 'Deactivate selected', + defaultMessage: 'Rules updates are in progress', } ); -export const BATCH_ACTION_DEACTIVATE_SELECTED_ERROR = (totalRules: number) => +export const BULK_EDIT_WARNING_TOAST_DESCRIPTION = (rulesCount: number) => i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deactivateSelectedErrorTitle', + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastDescription', { - values: { totalRules }, - defaultMessage: 'Error deactivating {totalRules, plural, =1 {rule} other {rules}}', + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} updating.', } ); -export const BATCH_ACTION_EXPORT_SELECTED = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.exportSelectedTitle', +export const BULK_EDIT_WARNING_TOAST_NOTIFY = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastNotifyButtonLabel', + { + defaultMessage: `Notify me when done`, + } +); + +export const BULK_EDIT_ERROR_TOAST_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastTitle', + { + defaultMessage: 'Rule updates failed', + } +); + +export const BULK_EDIT_ERROR_TOAST_DESCIRPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to update.', + } + ); + +export const BULK_EDIT_CONFIRMATION_TITLE = (elasticRulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationTitle', + { + values: { elasticRulesCount }, + defaultMessage: + '{elasticRulesCount, plural, =1 {# Elastic rule} other {# Elastic rules}} cannot be edited', + } + ); + +export const BULK_EDIT_CONFIRMATION_CANCEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditConfirmationCancelButtonLabel', + { + defaultMessage: 'Cancel', + } +); + +export const BULK_EDIT_CONFIRMATION_CONFIRM = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditConfirmation.confirmButtonLabel', + { + defaultMessage: 'Edit custom rules', + } +); + +export const BULK_EDIT_FLYOUT_FORM_SAVE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.saveButtonLabel', + { + defaultMessage: 'Save', + } +); + +export const BULK_EDIT_FLYOUT_FORM_CLOSE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.closeButtonLabel', + { + defaultMessage: 'Close', + } +); + +export const BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_HELP_TEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addIndexPatternsComboboxHelpText', + { + defaultMessage: + 'Select default index patterns of Elasticsearch indices from the dropdown. You can add custom index patterns and hit Enter to begin a new one.', + } +); + +export const BULK_EDIT_FLYOUT_FORM_DELETE_INDEX_PATTERNS_HELP_TEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteIndexPatternsComboboxHelpText', + { + defaultMessage: + 'Delete default index patterns of Elasticsearch indices from the dropdown. You can add custom index patterns and hit Enter to begin a new one.', + } +); + +export const BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addIndexPatternsComboboxLabel', { - defaultMessage: 'Export selected', + defaultMessage: 'Add index patterns for selected rules', } ); -export const BATCH_ACTION_DUPLICATE_SELECTED = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.duplicateSelectedTitle', +export const BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_REQUIRED_ERROR = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.indexPatternsRequiredErrorMessage', { - defaultMessage: 'Duplicate selected', + defaultMessage: 'A minimum of one index pattern is required.', } ); -export const BATCH_ACTION_DELETE_SELECTED = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedTitle', +export const BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addIndexPatternsTitle', { - defaultMessage: 'Delete selected', + defaultMessage: 'Add index patterns', } ); +export const BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_OVERWRITE_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addIndexPatternsOverwriteCheckboxLabel', + { + defaultMessage: 'Overwrite all selected rules index patterns', + } +); + +export const BULK_EDIT_FLYOUT_FORM_DELETE_INDEX_PATTERNS_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteIndexPatternsComboboxLabel', + { + defaultMessage: 'Delete index patterns for selected rules', + } +); + +export const BULK_EDIT_FLYOUT_FORM_DELETE_INDEX_PATTERNS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteIndexPatternsTitle', + { + defaultMessage: 'Delete index patterns', + } +); + +export const BULK_EDIT_FLYOUT_FORM_ADD_TAGS_HELP_TEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addTagsComboboxHelpText', + { + defaultMessage: + 'Add one or more custom identifying tags for selected rules. Press enter after each tag to begin a new one.', + } +); + +export const BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_HELP_TEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteTagsComboboxHelpText', + { + defaultMessage: + 'Delete one or more custom identifying tags for selected rules. Press enter after each tag to begin a new one.', + } +); + +export const BULK_EDIT_FLYOUT_FORM_ADD_TAGS_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addTagsComboboxLabel', + { + defaultMessage: 'Add tags for selected rules', + } +); + +export const BULK_EDIT_FLYOUT_FORM_TAGS_REQUIRED_ERROR = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.tagsComoboxRequiredErrorMessage', + { + defaultMessage: 'A minimum of one tag is required.', + } +); + +export const BULK_EDIT_FLYOUT_FORM_ADD_TAGS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addTagsTitle', + { + defaultMessage: 'Add tags', + } +); + +export const BULK_EDIT_FLYOUT_FORM_ADD_TAGS_OVERWRITE_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addTagsOverwriteCheckboxLabel', + { + defaultMessage: 'Overwrite all selected rules tags', + } +); + +export const BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteTagsComboboxLabel', + { + defaultMessage: 'Delete tags for selected rules', + } +); + +export const BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteTagsTitle', + { + defaultMessage: 'Delete tags', + } +); + +export const BATCH_ACTION_ACTIVATE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.activateSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error enabling {totalRules, plural, =1 {rule} other {rules}}', + } + ); + +export const BATCH_ACTION_DEACTIVATE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deactivateSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error disabling {totalRules, plural, =1 {rule} other {rules}}', + } + ); + export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle', { @@ -304,7 +563,7 @@ export const DUPLICATE_RULE_ERROR = i18n.translate( export const BULK_ACTION_FAILED = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription', { - defaultMessage: 'Failed to execte bulk action', + defaultMessage: 'Failed to execute bulk action', } ); @@ -385,10 +644,10 @@ export const COLUMN_SEE_ALL_POPOVER = i18n.translate( } ); -export const COLUMN_ACTIVATE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.columns.activateTitle', +export const COLUMN_ENABLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.columns.enabledTitle', { - defaultMessage: 'Activated', + defaultMessage: 'Enabled', } ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts index 4019e519e9db4..0d968eb402717 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts @@ -20,15 +20,18 @@ import { performBulkActionRoute } from './perform_bulk_action_route'; import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; import { loggingSystemMock } from 'src/core/server/mocks'; import { isElasticRule } from '../../../../usage/detections'; +import { readRules } from '../../rules/read_rules'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); jest.mock('../../../../usage/detections', () => ({ isElasticRule: jest.fn() })); +jest.mock('../../rules/read_rules', () => ({ readRules: jest.fn() })); describe.each([ ['Legacy', false], ['RAC', true], ])('perform_bulk_action - %s', (_, isRuleRegistryEnabled) => { const isElasticRuleMock = isElasticRule as jest.Mock; + const readRulesMock = readRules as jest.Mock; let server: ReturnType; let { clients, context } = requestContextMock.createTools(); let ml: ReturnType; @@ -249,6 +252,48 @@ describe.each([ expect(response.status).toEqual(500); expect(response.body.attributes.errors[0].message.length).toEqual(1000); }); + + it('returns partial failure error if one if rules from ids params can`t be fetched', async () => { + readRulesMock + .mockImplementationOnce(() => Promise.resolve(mockRule)) + .mockImplementationOnce(() => Promise.resolve(null)); + + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getPerformBulkActionSchemaMock(), + ids: [mockRule.id, 'failed-mock-id'], + query: undefined, + }, + }); + + const response = await server.inject(request, context); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + attributes: { + rules: { + failed: 1, + succeeded: 1, + total: 2, + }, + errors: [ + { + message: 'Can`t fetch a rule', + status_code: 500, + rules: [ + { + id: 'failed-mock-id', + }, + ], + }, + ], + }, + message: 'Bulk edit partially failed', + status_code: 500, + }); + }); }); describe('request validation', () => { @@ -297,6 +342,52 @@ describe.each([ expect(result.ok).toHaveBeenCalled(); }); + + it('rejects payloads with incorrect typing for ids', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { ...getPerformBulkActionSchemaMock(), ids: 'test fake' }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Invalid value "test fake" supplied to "ids"'); + }); + + it('rejects payload if there is more than 100 ids in payload', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getPerformBulkActionSchemaMock(), + query: undefined, + ids: Array.from({ length: 101 }).map(() => 'fake-id'), + }, + }); + + const response = await server.inject(request, context); + + expect(response.status).toEqual(400); + expect(response.body.message).toEqual('More than 100 ids sent for bulk edit action.'); + }); + + it('rejects payload if both query and ids defined', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getPerformBulkActionSchemaMock(), + query: '', + ids: ['fake-id'], + }, + }); + + const response = await server.inject(request, context); + + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'Both query and ids are sent. Define either ids or query in request payload.' + ); + }); }); it('should process large number of rules, larger than configured concurrency', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index 6e9d9d0e02d52..d4866082148a2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -11,9 +11,12 @@ import { Logger } from 'src/core/server'; import { RuleAlertType as Rule } from '../../rules/types'; +import type { RulesClient } from '../../../../../../alerting/server'; + import { DETECTION_ENGINE_RULES_BULK_ACTION, MAX_RULES_TO_UPDATE_IN_PARALLEL, + RULES_TABLE_MAX_PAGE_SIZE, } from '../../../../../common/constants'; import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas'; import { performBulkActionSchema } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema'; @@ -29,6 +32,7 @@ import { deleteRules } from '../../rules/delete_rules'; import { duplicateRule } from '../../rules/duplicate_rule'; import { enableRule } from '../../rules/enable_rule'; import { findRules } from '../../rules/find_rules'; +import { readRules } from '../../rules/read_rules'; import { patchRules } from '../../rules/patch_rules'; import { appplyBulkActionEditToRule } from '../../rules/bulk_action_edit'; import { getExportByObjectIds } from '../../rules/get_export_by_object_ids'; @@ -129,6 +133,87 @@ const executeBulkAction = async (rules: Rule[], action: RuleActionFn, abortSigna abortSignal, }); +const getRulesByIds = async ({ + ids, + rulesClient, + isRuleRegistryEnabled, + abortSignal, +}: { + ids: string[]; + rulesClient: RulesClient; + isRuleRegistryEnabled: boolean; + abortSignal: AbortSignal; +}) => { + const readRulesExecutor = async (id: string) => { + try { + const rule = await readRules({ id, rulesClient, isRuleRegistryEnabled, ruleId: undefined }); + if (rule == null) { + throw Error('Can`t fetch a rule'); + } + return { rule }; + } catch (err) { + const { message, statusCode } = transformError(err); + return { + error: { message, statusCode }, + rule: { id }, + }; + } + }; + + const { results } = await initPromisePool({ + concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, + items: ids, + executor: readRulesExecutor, + abortSignal, + }); + + return { + total: ids.length, + rules: results.filter((rule) => rule.error === undefined).map(({ rule }) => rule) as Rule[], + fetchErrors: results.filter((rule): rule is RuleActionError => rule.error !== undefined), + }; +}; + +const fetchRules = async ({ + query, + ids, + rulesClient, + isRuleRegistryEnabled, + abortSignal, +}: { + query: string | undefined; + ids: string[] | undefined; + rulesClient: RulesClient; + isRuleRegistryEnabled: boolean; + abortSignal: AbortSignal; +}) => { + if (ids) { + return getRulesByIds({ + ids, + rulesClient, + isRuleRegistryEnabled, + abortSignal, + }); + } + + const { data, total } = await findRules({ + isRuleRegistryEnabled, + rulesClient, + perPage: MAX_RULES_TO_PROCESS_TOTAL, + filter: query !== '' ? query : undefined, + page: undefined, + sortField: undefined, + sortOrder: undefined, + fields: undefined, + }); + + return { + rules: data, + total, + fetchErrors: [] as RuleActionError[], + }; +}; + export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, ml: SetupPlugins['ml'], @@ -151,6 +236,21 @@ export const performBulkActionRoute = ( async (context, request, response) => { const { body } = request; const siemResponse = buildSiemResponse(response); + + if (body?.ids && body.ids.length > RULES_TABLE_MAX_PAGE_SIZE) { + return siemResponse.error({ + body: `More than ${RULES_TABLE_MAX_PAGE_SIZE} ids sent for bulk edit action.`, + statusCode: 400, + }); + } + + if (body?.ids && body.query !== undefined) { + return siemResponse.error({ + body: `Both query and ids are sent. Define either ids or query in request payload.`, + statusCode: 400, + }); + } + const abortController = new AbortController(); // subscribing to completed$, because it handles both cases when request was completed and aborted. @@ -170,18 +270,15 @@ export const performBulkActionRoute = ( savedObjectsClient, }); - const rules = await findRules({ + const { rules, total, fetchErrors } = await fetchRules({ isRuleRegistryEnabled, rulesClient, - perPage: MAX_RULES_TO_PROCESS_TOTAL, - filter: body.query !== '' ? body.query : undefined, - page: undefined, - sortField: undefined, - sortOrder: undefined, - fields: undefined, + query: body.query, + ids: body.ids, + abortSignal: abortController.signal, }); - if (rules.total > MAX_RULES_TO_PROCESS_TOTAL) { + if (total > MAX_RULES_TO_PROCESS_TOTAL) { return siemResponse.error({ body: `More than ${MAX_RULES_TO_PROCESS_TOTAL} rules matched the filter query. Try to narrow it down.`, statusCode: 400, @@ -196,7 +293,7 @@ export const performBulkActionRoute = ( switch (body.action) { case BulkAction.enable: processingResponse = await executeBulkAction( - rules.data, + rules, async (rule) => { if (!rule.enabled) { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); @@ -211,7 +308,7 @@ export const performBulkActionRoute = ( break; case BulkAction.disable: processingResponse = await executeBulkAction( - rules.data, + rules, async (rule) => { if (rule.enabled) { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); @@ -223,7 +320,7 @@ export const performBulkActionRoute = ( break; case BulkAction.delete: processingResponse = await executeBulkAction( - rules.data, + rules, async (rule) => { await deleteRules({ ruleId: rule.id, @@ -236,7 +333,7 @@ export const performBulkActionRoute = ( break; case BulkAction.duplicate: processingResponse = await executeBulkAction( - rules.data, + rules, async (rule) => { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); @@ -252,7 +349,7 @@ export const performBulkActionRoute = ( rulesClient, exceptionsClient, savedObjectsClient, - rules.data.map(({ params }) => ({ rule_id: params.ruleId })), + rules.map(({ params }) => ({ rule_id: params.ruleId })), logger, isRuleRegistryEnabled ); @@ -268,7 +365,7 @@ export const performBulkActionRoute = ( }); case BulkAction.edit: processingResponse = await executeBulkAction( - rules.data, + rules, async (rule) => { throwHttpError({ valid: !isElasticRule(rule.tags), @@ -302,13 +399,15 @@ export const performBulkActionRoute = ( throw Error('Bulk action was aborted'); } - const errors = processingResponse.results.filter( - (resp): resp is RuleActionError => resp?.error !== undefined - ); - const rulesCount = rules.data.length; + const errors = [ + ...fetchErrors, + ...processingResponse.results.filter( + (resp): resp is RuleActionError => resp?.error !== undefined + ), + ]; if (errors.length > 0) { - const responseBody = getErrorResponseBody(errors, rulesCount); + const responseBody = getErrorResponseBody(errors, total); return response.custom({ headers: { @@ -322,7 +421,7 @@ export const performBulkActionRoute = ( return response.ok({ body: { success: true, - rules_count: rulesCount, + rules_count: total, }, }); } catch (err) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cc4035fbaf301..8e47a12f9ba4a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22806,15 +22806,9 @@ "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsToolTip": "Kibana アクション特権がありません", "xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription": "ルールのエクスポート", "xpack.securitySolution.detectionEngine.rules.allRules.activeRuleDescription": "アクティブ", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.activateSelectedTitle": "選択した項目の有効化", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deactivateSelectedTitle": "選択した項目の無効化", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle": "選択には削除できないイミュータブルルールがあります", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedTitle": "選択した項目を削除", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.duplicateSelectedTitle": "選択した項目の複製", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.exportSelectedTitle": "選択した項目のエクスポート", "xpack.securitySolution.detectionEngine.rules.allRules.batchActionsTitle": "一斉アクション", "xpack.securitySolution.detectionEngine.rules.allRules.clearSelectionTitle": "選択した項目をクリア", - "xpack.securitySolution.detectionEngine.rules.allRules.columns.activateTitle": "有効化", "xpack.securitySolution.detectionEngine.rules.allRules.columns.gap": "最後のギャップ(該当する場合)", "xpack.securitySolution.detectionEngine.rules.allRules.columns.gapTooltip": "ルール実行の最新のギャップの期間。ルールルックバックを調整するか、ギャップの軽減については{seeDocs}してください。", "xpack.securitySolution.detectionEngine.rules.allRules.columns.indexingTimes": "インデックス時間(ミリ秒)", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7cffdf21f4ccc..f2b19cf866e83 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23157,17 +23157,11 @@ "xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription": "导出规则", "xpack.securitySolution.detectionEngine.rules.allRules.activeRuleDescription": "活动", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.activateSelectedErrorTitle": "激活{totalRules, plural, other {规则}}时出错", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.activateSelectedTitle": "激活所选", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deactivateSelectedErrorTitle": "停用{totalRules, plural, other {规则}}时出错", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deactivateSelectedTitle": "停用所选", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle": "删除{totalRules, plural, other {规则}}时出错", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle": "选择内容包含无法删除的不可变规则", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedTitle": "删除所选", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.duplicateSelectedTitle": "复制所选", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.exportSelectedTitle": "导出所选", "xpack.securitySolution.detectionEngine.rules.allRules.batchActionsTitle": "批处理操作", "xpack.securitySolution.detectionEngine.rules.allRules.clearSelectionTitle": "清除所选内容", - "xpack.securitySolution.detectionEngine.rules.allRules.columns.activateTitle": "已激活", "xpack.securitySolution.detectionEngine.rules.allRules.columns.gap": "上一缺口(如果有)", "xpack.securitySolution.detectionEngine.rules.allRules.columns.gapTooltip": "规则执行中最近缺口的持续时间。调整规则回查或{seeDocs}以缩小缺口。", "xpack.securitySolution.detectionEngine.rules.allRules.columns.indexingTimes": "索引时间 (ms)", diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts index 1643c4851c024..06223fbca7070 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts @@ -326,5 +326,38 @@ export default ({ getService }: FtrProviderContext): void => { expect(responses.filter((r) => r.body.statusCode === 429).length).to.eql(5); }); + + it('should bulk update rule by id', async () => { + const ruleId = 'ruleId'; + const timelineId = '91832785-286d-4ebe-b884-1a208d111a70'; + const timelineTitle = 'Test timeline'; + await createRule(supertest, log, getSimpleRule(ruleId)); + const { + body: { id }, + } = await fetchRule(ruleId); + + const { body } = await postBulkAction() + .send({ + ids: [id], + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: timelineId, + timeline_title: timelineTitle, + }, + }, + ], + }) + .expect(200); + + expect(body).to.eql({ success: true, rules_count: 1 }); + + const { body: rule } = await fetchRule(ruleId).expect(200); + + expect(rule.timeline_id).to.eql(timelineId); + expect(rule.timeline_title).to.eql(timelineTitle); + }); }); }; From 363ad1490aa0106c215061ff9cd43044d82b671e Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 31 Jan 2022 14:49:05 +0200 Subject: [PATCH 13/65] [Lens] Fixes heatmap tests flakiness (#124080) --- x-pack/test/functional/apps/lens/heatmap.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/x-pack/test/functional/apps/lens/heatmap.ts b/x-pack/test/functional/apps/lens/heatmap.ts index deb344e7eb8b5..618f1141d8431 100644 --- a/x-pack/test/functional/apps/lens/heatmap.ts +++ b/x-pack/test/functional/apps/lens/heatmap.ts @@ -9,13 +9,12 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const PageObjects = getPageObjects(['visualize', 'lens', 'common']); const elasticChart = getService('elasticChart'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); - // FLAKY: https://github.com/elastic/kibana/issues/124075 - describe.skip('lens heatmap', () => { + describe('lens heatmap', () => { before(async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); @@ -99,7 +98,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should not change when passing from percentage to number', async () => { await testSubjects.click('lnsPalettePanel_dynamicColoring_rangeType_groups_number'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.waitForVisualization(); const debugState = await PageObjects.lens.getCurrentChartDebugState(); @@ -125,7 +124,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_0', '0', { clearWithKeyboard: true, }); - await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.waitForVisualization(); const debugState = await PageObjects.lens.getCurrentChartDebugState(); @@ -145,7 +144,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should reset stop numbers when changing palette', async () => { await PageObjects.lens.changePaletteTo('status'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.waitForVisualization(); const debugState = await PageObjects.lens.getCurrentChartDebugState(); @@ -165,7 +164,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should not change when passing from number to percent', async () => { await testSubjects.click('lnsPalettePanel_dynamicColoring_rangeType_groups_percent'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.waitForVisualization(); const debugState = await PageObjects.lens.getCurrentChartDebugState(); From 920b4fcb04d6293f02d054d8e4ba7569a39dc96b Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 31 Jan 2022 14:03:56 +0100 Subject: [PATCH 14/65] remove leftover deprecated imports (#124006) --- .../public/components/sidebar/sidebar.tsx | 2 +- .../public/components/sidebar/state/reducers.ts | 6 +++--- src/plugins/visualizations/common/locator.ts | 3 +-- src/plugins/visualizations/public/vis.test.ts | 2 +- src/plugins/visualizations/public/vis.ts | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index f1eebbbdf2116..5fcf3d4ecf133 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -104,7 +104,7 @@ function DefaultEditorSideBarComponent({ ...vis.serialize(), params: state.params, data: { - aggs: state.data.aggs ? (state.data.aggs.aggs.map((agg) => agg.toJSON()) as any) : [], + aggs: state.data.aggs ? (state.data.aggs.aggs.map((agg) => agg.serialize()) as any) : [], }, }); embeddableHandler.reload(); diff --git a/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts b/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts index ee2b715fad25f..54012b9d89590 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts +++ b/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts @@ -58,7 +58,7 @@ const createEditorStateReducer = if (agg.id === aggId) { agg.type = value; - return agg.toJSON(); + return agg.serialize(); } return agg; @@ -78,7 +78,7 @@ const createEditorStateReducer = const newAggs = state.data.aggs!.aggs.map((agg) => { if (agg.id === aggId) { - const parsedAgg = agg.toJSON(); + const parsedAgg = agg.serialize(); return { ...parsedAgg, @@ -169,7 +169,7 @@ const createEditorStateReducer = const newAggs = state.data.aggs!.aggs.map((agg) => { if (agg.id === aggId) { - const parsedAgg = agg.toJSON(); + const parsedAgg = agg.serialize(); return { ...parsedAgg, diff --git a/src/plugins/visualizations/common/locator.ts b/src/plugins/visualizations/common/locator.ts index b294019e83392..a1d15ee5188d3 100644 --- a/src/plugins/visualizations/common/locator.ts +++ b/src/plugins/visualizations/common/locator.ts @@ -11,10 +11,9 @@ import { omitBy } from 'lodash'; import type { ParsedQuery } from 'query-string'; import { stringify } from 'query-string'; import rison from 'rison-node'; -import { Filter } from '@kbn/es-query'; +import { Filter, isFilterPinned } from '@kbn/es-query'; import type { Query, RefreshInterval, TimeRange } from 'src/plugins/data/common'; import type { LocatorDefinition, LocatorPublic } from 'src/plugins/share/common'; -import { isFilterPinned } from '../../data/common'; import { url } from '../../kibana_utils/common'; import { GLOBAL_STATE_STORAGE_KEY, STATE_STORAGE_KEY, VisualizeConstants } from './constants'; import type { SavedVisState } from './types'; diff --git a/src/plugins/visualizations/public/vis.test.ts b/src/plugins/visualizations/public/vis.test.ts index bfe69f9c59a36..a3fb8abf76bcd 100644 --- a/src/plugins/visualizations/public/vis.test.ts +++ b/src/plugins/visualizations/public/vis.test.ts @@ -38,7 +38,7 @@ jest.mock('./services', () => { getTypes: () => ({ get: () => visType }), getAggs: () => ({ createAggConfigs: (indexPattern: any, cfg: any) => ({ - aggs: cfg.map((aggConfig: any) => ({ ...aggConfig, toJSON: () => aggConfig })), + aggs: cfg.map((aggConfig: any) => ({ ...aggConfig, serialize: () => aggConfig })), }), }), getSearch: () => ({ diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts index fd9bb434a7cf0..821becf3da9c1 100644 --- a/src/plugins/visualizations/public/vis.ts +++ b/src/plugins/visualizations/public/vis.ts @@ -189,7 +189,7 @@ export class Vis { } serialize(): SerializedVis { - const aggs = this.data.aggs ? this.data.aggs.aggs.map((agg) => agg.toJSON()) : []; + const aggs = this.data.aggs ? this.data.aggs.aggs.map((agg) => agg.serialize()) : []; return { id: this.id, title: this.title, From 89fc72f955462b01a35facfe543084056036bc7a Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 31 Jan 2022 14:12:39 +0100 Subject: [PATCH 15/65] prevent leakage of moment timezone (#123694) --- .../response_processors/series/time_shift.js | 42 +++++++------ .../series/time_shift.test.js | 5 ++ .../expressions/time_scale/time_scale.test.ts | 30 ++++++++++ .../expressions/time_scale/time_scale_fn.ts | 60 +++++++++++-------- 4 files changed, 92 insertions(+), 45 deletions(-) diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/time_shift.js b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/time_shift.js index 429050fab36cc..6936ea7e1c5d7 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/time_shift.js +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/time_shift.js @@ -27,28 +27,32 @@ export function timeShift( const offsetValue = matches[1]; const offsetUnit = matches[2]; - let defaultTimezone; - if (!panel.ignore_daylight_time) { - // the datemath plugin always parses dates by using the current default moment time zone. - // to use the configured time zone, we are switching just for the bounds calculation. - defaultTimezone = moment().zoneName(); - moment.tz.setDefault(timezone); - } + const defaultTimezone = moment().zoneName(); + try { + if (!panel.ignore_daylight_time) { + // the datemath plugin always parses dates by using the current default moment time zone. + // to use the configured time zone, we are switching just for the bounds calculation. - results.forEach((item) => { - if (startsWith(item.id, series.id)) { - item.data = item.data.map((row) => [ - (panel.ignore_daylight_time ? moment.utc : moment)(row[0]) - .add(offsetValue, offsetUnit) - .valueOf(), - row[1], - ]); + // The code between this call and the reset in the finally block is not allowed to get async, + // otherwise the timezone setting can leak out of this function. + moment.tz.setDefault(timezone); } - }); - if (!panel.ignore_daylight_time) { - // reset default moment timezone - moment.tz.setDefault(defaultTimezone); + results.forEach((item) => { + if (startsWith(item.id, series.id)) { + item.data = item.data.map((row) => [ + (panel.ignore_daylight_time ? moment.utc : moment)(row[0]) + .add(offsetValue, offsetUnit) + .valueOf(), + row[1], + ]); + } + }); + } finally { + if (!panel.ignore_daylight_time) { + // reset default moment timezone + moment.tz.setDefault(defaultTimezone); + } } } } diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/time_shift.test.js b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/time_shift.test.js index 7fff2603cf47a..f3000e5589b6d 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/time_shift.test.js +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/time_shift.test.js @@ -128,4 +128,9 @@ describe('timeShift(resp, panel, series)', () => { [dateAfterDST + 1000 * 60 * 60 * 24, 2], ]); }); + + test('processor is sync to avoid timezone setting leakage', () => { + const result = timeShift(resp, panel, series, {})((results) => results)([]); + expect(Array.isArray(result)).toBe(true); + }); }); diff --git a/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts b/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts index d51f2594b4267..9bf647b194282 100644 --- a/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts +++ b/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts @@ -388,4 +388,34 @@ describe('time_scale', () => { expect(result.rows.map(({ scaledMetric }) => scaledMetric)).toEqual([1, 1, 1, 1, 1]); }); + + it('should be sync except for timezone getter to prevent timezone leakage', async () => { + let resolveTimezonePromise: (value: string | PromiseLike) => void; + const timezonePromise = new Promise((res) => { + resolveTimezonePromise = res; + }); + const timeScaleResolved = jest.fn((x) => x); + const delayedTimeScale = getTimeScale(() => timezonePromise); + const delayedTimeScaleWrapper = functionWrapper(delayedTimeScale); + const result = delayedTimeScaleWrapper( + { + ...emptyTable, + }, + { + ...defaultArgs, + } + ).then(timeScaleResolved) as Promise; + + expect(result instanceof Promise).toBe(true); + // wait a tick + await new Promise((r) => setTimeout(r, 0)); + // time scale is not done yet because it's waiting for the timezone + expect(timeScaleResolved).not.toHaveBeenCalled(); + // resolve timezone + resolveTimezonePromise!('UTC'); + // wait a tick + await new Promise((r) => setTimeout(r, 0)); + // should resolve now without another async dependency + expect(timeScaleResolved).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/lens/common/expressions/time_scale/time_scale_fn.ts b/x-pack/plugins/lens/common/expressions/time_scale/time_scale_fn.ts index 78d2e9896d4c3..5217d6a7db167 100644 --- a/x-pack/plugins/lens/common/expressions/time_scale/time_scale_fn.ts +++ b/x-pack/plugins/lens/common/expressions/time_scale/time_scale_fn.ts @@ -9,6 +9,7 @@ import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; import { buildResultColumns, + Datatable, ExecutionContext, } from '../../../../../../src/plugins/expressions/common'; import { @@ -76,38 +77,45 @@ export const timeScaleFn = } // the datemath plugin always parses dates by using the current default moment time zone. // to use the configured time zone, we are switching just for the bounds calculation. + + // The code between this call and the reset in the finally block is not allowed to get async, + // otherwise the timezone setting can leak out of this function. const defaultTimezone = moment().zoneName(); - moment.tz.setDefault(timeInfo.timeZone); + let result: Datatable; + try { + moment.tz.setDefault(timeInfo.timeZone); - const timeBounds = timeInfo.timeRange && calculateBounds(timeInfo.timeRange); + const timeBounds = timeInfo.timeRange && calculateBounds(timeInfo.timeRange); - const result = { - ...input, - columns: resultColumns, - rows: input.rows.map((row) => { - const newRow = { ...row }; + result = { + ...input, + columns: resultColumns, + rows: input.rows.map((row) => { + const newRow = { ...row }; - let startOfBucket = moment(row[dateColumnId]); - let endOfBucket = startOfBucket.clone().add(intervalDuration); - if (timeBounds && timeBounds.min) { - startOfBucket = moment.max(startOfBucket, timeBounds.min); - } - if (timeBounds && timeBounds.max) { - endOfBucket = moment.min(endOfBucket, timeBounds.max); - } - const bucketSize = endOfBucket.diff(startOfBucket); - const factor = bucketSize / targetUnitInMs; + let startOfBucket = moment(row[dateColumnId]); + let endOfBucket = startOfBucket.clone().add(intervalDuration); + if (timeBounds && timeBounds.min) { + startOfBucket = moment.max(startOfBucket, timeBounds.min); + } + if (timeBounds && timeBounds.max) { + endOfBucket = moment.min(endOfBucket, timeBounds.max); + } + const bucketSize = endOfBucket.diff(startOfBucket); + const factor = bucketSize / targetUnitInMs; - const currentValue = newRow[inputColumnId]; - if (currentValue != null) { - newRow[outputColumnId] = Number(currentValue) / factor; - } + const currentValue = newRow[inputColumnId]; + if (currentValue != null) { + newRow[outputColumnId] = Number(currentValue) / factor; + } - return newRow; - }), - }; - // reset default moment timezone - moment.tz.setDefault(defaultTimezone); + return newRow; + }), + }; + } finally { + // reset default moment timezone + moment.tz.setDefault(defaultTimezone); + } return result; }; From fabaa8879138dddcb2db41c99c76b272439adfc1 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 31 Jan 2022 14:31:39 +0100 Subject: [PATCH 16/65] [Lens] Fix records field name and migrate (#123894) --- x-pack/plugins/lens/common/constants.ts | 3 + .../datapanel.test.tsx | 5 +- .../dimension_panel/dimension_panel.test.tsx | 18 ++--- .../droppable/droppable.test.ts | 20 ++--- .../dimension_panel/field_input.test.tsx | 2 +- .../indexpattern_datasource/document_field.ts | 5 +- .../field_item.test.tsx | 3 +- .../indexpattern.test.ts | 34 ++++---- .../indexpattern_suggestions.test.tsx | 14 ++-- .../operations/definitions.test.ts | 2 +- .../definitions/filters/filters.test.tsx | 2 +- .../formula/editor/math_completion.test.ts | 2 +- .../definitions/formula/formula.test.tsx | 2 +- .../definitions/last_value.test.tsx | 8 +- .../definitions/ranges/ranges.test.tsx | 2 +- .../definitions/terms/helpers.test.ts | 2 +- .../definitions/terms/terms.test.tsx | 10 +-- .../operations/layer_helpers.test.ts | 42 +++++----- .../operations/time_scale_utils.test.ts | 2 +- .../make_lens_embeddable_factory.ts | 7 +- .../server/migrations/common_migrations.ts | 25 +++++- .../saved_object_migrations.test.ts | 80 ++++++++++++++++++- .../migrations/saved_object_migrations.ts | 16 ++-- 23 files changed, 207 insertions(+), 99 deletions(-) diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index edf7654deb7b7..f0db3385cefc1 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -22,6 +22,9 @@ export const layerTypes: Record = { REFERENCELINE: 'referenceLine', }; +// might collide with user-supplied field names, try to make as unique as possible +export const DOCUMENT_FIELD_NAME = '___records___'; + export function getBasePath() { return `#/`; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 27d03a1e3edc8..7fe115847b2b5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -27,6 +27,7 @@ import { indexPatternFieldEditorPluginMock } from '../../../../../src/plugins/da import { getFieldByNameFactory } from './pure_helpers'; import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/public/mocks'; import { TermsIndexPatternColumn } from './operations'; +import { DOCUMENT_FIELD_NAME } from '../../common'; const fieldsOne = [ { @@ -640,7 +641,7 @@ describe('IndexPattern Data Panel', () => { }); it('should list all supported fields in the pattern sorted alphabetically in groups', async () => { const wrapper = mountWithIntl(); - expect(wrapper.find(FieldItem).first().prop('field').name).toEqual('Records'); + expect(wrapper.find(FieldItem).first().prop('field').displayName).toEqual('Records'); expect( wrapper .find('[data-test-subj="lnsIndexPatternAvailableFields"]') @@ -813,7 +814,7 @@ describe('IndexPattern Data Panel', () => { wrapper.find('[data-test-subj="typeFilter-document"]').first().simulate('click'); expect(wrapper.find(FieldItem).map((fieldItem) => fieldItem.prop('field').name)).toEqual([ - 'Records', + DOCUMENT_FIELD_NAME, ]); expect(wrapper.find(NoFieldsCallout).length).toEqual(3); }); 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 dd16b0be6ce61..975e08e5ca1a9 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 @@ -350,7 +350,7 @@ describe('IndexPatternDimensionEditorPanel', () => { .filter('[data-test-subj="indexPattern-dimension-field"]') .prop('options'); - expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records'); + expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-___records___'); expect( options![1].options!.filter(({ label }) => label === 'timestampLabel')[0]['data-test-subj'] @@ -481,7 +481,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, })} /> @@ -991,7 +991,7 @@ describe('IndexPatternDimensionEditorPanel', () => { const newColumnState = setState.mock.calls[0][0](state).layers.first.columns.col2; expect(newColumnState.operationType).toEqual('count'); - expect(newColumnState.sourceField).toEqual('Records'); + expect(newColumnState.sourceField).toEqual('___records___'); }); it('should indicate document and field compatibility with selected document operation', () => { @@ -1004,7 +1004,7 @@ describe('IndexPatternDimensionEditorPanel', () => { isBucketed: false, label: '', operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, })} columnId="col2" @@ -1090,7 +1090,7 @@ describe('IndexPatternDimensionEditorPanel', () => { isBucketed: false, label: 'Count of records', operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', ...colOverrides, } as GenericIndexPatternColumn, }), @@ -1320,7 +1320,7 @@ describe('IndexPatternDimensionEditorPanel', () => { isBucketed: false, label: 'Count of records', operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', ...colOverrides, } as GenericIndexPatternColumn, }), @@ -1337,7 +1337,7 @@ describe('IndexPatternDimensionEditorPanel', () => { isBucketed: false, label: 'Count of records', operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', } as GenericIndexPatternColumn, }), columnId: 'col2', @@ -1524,7 +1524,7 @@ describe('IndexPatternDimensionEditorPanel', () => { isBucketed: false, label: 'Count of records', operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', ...colOverrides, } as GenericIndexPatternColumn, }), @@ -1871,7 +1871,7 @@ describe('IndexPatternDimensionEditorPanel', () => { isBucketed: false, label: '', operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, })} columnId={'col2'} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index fab431e886d8e..3871715cf31e5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -713,7 +713,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -1186,7 +1186,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -1249,7 +1249,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, ref2: { @@ -1329,7 +1329,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, ref2: { @@ -1415,7 +1415,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, col2: { @@ -1605,7 +1605,7 @@ describe('IndexPatternDimensionEditorPanel', () => { // Private operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', customLabel: true, }, }, @@ -1822,7 +1822,7 @@ describe('IndexPatternDimensionEditorPanel', () => { operationType: 'count', label: '', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', customLabel: true, }, col6: { @@ -1830,7 +1830,7 @@ describe('IndexPatternDimensionEditorPanel', () => { operationType: 'count', label: '', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', customLabel: true, }, }, @@ -1862,14 +1862,14 @@ describe('IndexPatternDimensionEditorPanel', () => { operationType: 'count', label: '', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', }), col6: expect.objectContaining({ dataType: 'number', operationType: 'count', label: '', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', }), }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx index cf409ebfd680d..730342ae694e8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx @@ -98,7 +98,7 @@ function getCountOperationColumn(): GenericIndexPatternColumn { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/document_field.ts b/x-pack/plugins/lens/public/indexpattern_datasource/document_field.ts index fef78b1d5c6de..1f69d1df5602a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/document_field.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/document_field.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { DOCUMENT_FIELD_NAME } from '../../common'; import { IndexPatternField } from './types'; /** @@ -16,9 +17,7 @@ export const documentField: IndexPatternField = { displayName: i18n.translate('xpack.lens.indexPattern.records', { defaultMessage: 'Records', }), - name: i18n.translate('xpack.lens.indexPattern.records', { - defaultMessage: 'Records', - }), + name: DOCUMENT_FIELD_NAME, type: 'document', aggregatable: true, searchable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx index 868c5864a6730..cb721416a1df9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx @@ -18,6 +18,7 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { documentField } from './document_field'; import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/public/mocks'; import { FieldFormatsStart } from '../../../../../src/plugins/field_formats/public'; +import { DOCUMENT_FIELD_NAME } from '../../common'; const chartsThemeService = chartPluginMock.createSetupContract().theme; @@ -254,7 +255,7 @@ describe('IndexPattern Field Item', () => { it('should not request field stats for document field', async () => { const wrapper = mountWithIntl(); - clickField(wrapper, 'Records'); + clickField(wrapper, DOCUMENT_FIELD_NAME); expect(core.http.post).not.toHaveBeenCalled(); expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true); 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 4e358564c9f76..404c31010278b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -224,7 +224,7 @@ describe('IndexPattern Data Source', () => { isBucketed: false, label: 'Foo', operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }; const map = indexPatternDatasource.uniqueLabels({ layers: { @@ -353,7 +353,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, col2: { @@ -479,7 +479,7 @@ describe('IndexPattern Data Source', () => { Object { "arguments": Object { "idMap": Array [ - "{\\"col-0-0\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-1\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", + "{\\"col-0-0\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"___records___\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-1\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", ], }, "function": "lens_rename_columns", @@ -503,7 +503,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, col2: { @@ -549,7 +549,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', timeShift: '1d', }, @@ -586,7 +586,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', timeScale: 'h', filter: { @@ -716,7 +716,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', timeScale: 'h', }, @@ -809,7 +809,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', timeScale: 'h', }, @@ -848,7 +848,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, bucket1: { @@ -1025,7 +1025,7 @@ describe('IndexPattern Data Source', () => { operationType: 'count', isBucketed: false, scale: 'ratio', - sourceField: 'Records', + sourceField: '___records___', customLabel: true, }, date: { @@ -1057,7 +1057,7 @@ describe('IndexPattern Data Source', () => { operationType: 'count', isBucketed: false, scale: 'ratio', - sourceField: 'Records', + sourceField: '___records___', customLabel: true, }, math: { @@ -1589,7 +1589,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records2', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -1602,7 +1602,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, bucket1: { @@ -1645,7 +1645,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -1669,7 +1669,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -1698,7 +1698,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -1753,7 +1753,7 @@ describe('IndexPattern Data Source', () => { operationType: 'count', isBucketed: false, scale: 'ratio', - sourceField: 'Records', + sourceField: '___records___', }, }, columnOrder: ['fa649155-d7f5-49d9-af26-508287431244'], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index f9f720cfa922a..ab7801d0d8760 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1063,7 +1063,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, }, }, @@ -2483,7 +2483,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, ref: { label: '', @@ -2665,7 +2665,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, ref: { label: '', @@ -2679,21 +2679,21 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, metric3: { label: '', dataType: 'number', isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, metric4: { label: '', dataType: 'number', isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, ref2: { label: '', @@ -2842,7 +2842,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts index 974e37c2aea8e..1a0da126f28e3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts @@ -91,7 +91,7 @@ const baseColumnArgs: { // Private operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, indexPattern, layer: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index 8d0a07cffd2e1..5a3700c165d20 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -79,7 +79,7 @@ describe('filters', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts index 9b7f34b583631..612ae892146fc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts @@ -58,7 +58,7 @@ const operationDefinitionMap: Record = { input: 'field', buildColumn: buildGenericColumn('count'), getPossibleOperationForField: (field: IndexPatternField) => - field.name === 'Records' ? numericOperation() : null, + field.name === '___records___' ? numericOperation() : null, } as unknown as GenericOperationDefinition, last_value: { type: 'last_value', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx index d1561e93aa807..a0e5fca114c37 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx @@ -265,7 +265,7 @@ describe('formula', () => { previousColumn: { ...layer.columns.col1, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', filter: { language: 'lucene', query: `*`, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx index 26074b47e0f48..652c644d653fb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx @@ -242,7 +242,7 @@ describe('last_value', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -296,7 +296,7 @@ describe('last_value', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -359,7 +359,7 @@ describe('last_value', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -378,7 +378,7 @@ describe('last_value', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', params: { format: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx index 9b1fde8561363..79103841ef4ba 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx @@ -159,7 +159,7 @@ describe('ranges', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts index 4468953a26d17..628a5d6a7740a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts @@ -86,7 +86,7 @@ function getCountOperationColumn( label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', ...params, }; 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 bc1106ad3debf..fb3943289437d 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 @@ -82,7 +82,7 @@ describe('terms', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -434,7 +434,7 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -544,7 +544,7 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -630,7 +630,7 @@ describe('terms', () => { dataType: 'number', isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, }, columnOrder: [], @@ -779,7 +779,7 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, 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 9066641b522d9..dad1250b39e14 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 @@ -378,7 +378,7 @@ describe('state_helpers', () => { // Private operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, }, }; @@ -414,7 +414,7 @@ describe('state_helpers', () => { // Private operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, }, }; @@ -575,7 +575,7 @@ describe('state_helpers', () => { // Private operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, }, }, @@ -1292,7 +1292,7 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -1351,7 +1351,7 @@ describe('state_helpers', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -1376,7 +1376,7 @@ describe('state_helpers', () => { id1: expect.objectContaining({ dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }), willBeReference: expect.objectContaining({ @@ -1435,7 +1435,7 @@ describe('state_helpers', () => { customLabel: true, dataType: 'number' as const, isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count' as const, }; @@ -1472,7 +1472,7 @@ describe('state_helpers', () => { customLabel: true, dataType: 'number' as const, isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count' as const, }; @@ -1607,7 +1607,7 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number' as const, isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count' as const, }, }, @@ -1647,7 +1647,7 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number' as const, isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count' as const, }, }, @@ -1720,7 +1720,7 @@ describe('state_helpers', () => { isBucketed: false, operationType: 'count' as const, - sourceField: 'Records', + sourceField: '___records___', }, invalid: { label: 'Test reference', @@ -1772,7 +1772,7 @@ describe('state_helpers', () => { isBucketed: false, operationType: 'count' as const, - sourceField: 'Records', + sourceField: '___records___', }, invalid: { label: 'Test reference', @@ -1990,7 +1990,7 @@ describe('state_helpers', () => { isBucketed: false, operationType: 'count' as const, - sourceField: 'Records', + sourceField: '___records___', filter: { language: 'kuery', query: 'bytes > 4000' }, timeShift: '3h', }; @@ -2168,7 +2168,7 @@ describe('state_helpers', () => { columnOrder: ['col1', 'col2'], columns: { col1: expect.objectContaining({ - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }), col2: expect.objectContaining({ references: ['col1'] }), @@ -2228,7 +2228,7 @@ describe('state_helpers', () => { label: 'Count of records', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -2282,7 +2282,7 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number', isBucketed: false, - sourceField: 'Records', + sourceField: '___records___', operationType: 'count', }, }, @@ -2309,7 +2309,7 @@ describe('state_helpers', () => { isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, col2: { label: 'Test reference', @@ -2337,7 +2337,7 @@ describe('state_helpers', () => { isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, col2: { label: 'Changed label', @@ -2382,7 +2382,7 @@ describe('state_helpers', () => { isBucketed: false, operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', }, col2: { label: 'Test reference', @@ -2566,7 +2566,7 @@ describe('state_helpers', () => { operationType: 'count', isBucketed: false, scale: 'ratio', - sourceField: 'Records', + sourceField: '___records___', customLabel: true, }, date: { @@ -2598,7 +2598,7 @@ describe('state_helpers', () => { operationType: 'count', isBucketed: false, scale: 'ratio', - sourceField: 'Records', + sourceField: '___records___', customLabel: true, }, math: { 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 30f8c85a81b90..1eb02fa82ceef 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 @@ -99,7 +99,7 @@ describe('time scale utils', () => { describe('adjustTimeScaleOnOtherColumnChange', () => { const baseColumn: GenericIndexPatternColumn = { operationType: 'count', - sourceField: 'Records', + sourceField: '___records___', label: 'Count of records per second', dataType: 'number', isBucketed: false, diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts index f516a99b078f2..332f20a00f66d 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -17,6 +17,7 @@ import { commonRemoveTimezoneDateHistogramParam, commonRenameFilterReferences, commonRenameOperationsForFormula, + commonRenameRecordsField, commonUpdateVisLayerType, getLensFilterMigrations, } from '../migrations/common_migrations'; @@ -68,8 +69,10 @@ export const makeLensEmbeddableFactory = } as unknown as SerializableRecord; }, '8.1.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape715 }; - const migratedLensState = commonRenameFilterReferences(lensState.attributes); + const lensState = state as unknown as { attributes: LensDocShape715 }; + const migratedLensState = commonRenameRecordsField( + commonRenameFilterReferences(lensState.attributes) + ); return { ...lensState, attributes: migratedLensState, diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index 82cde025e31ed..5f3ea4d57fa2c 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -22,8 +22,9 @@ import { VisStatePost715, VisStatePre715, VisState716, + LensDocShape810, } from './types'; -import { CustomPaletteParams, layerTypes } from '../../common'; +import { CustomPaletteParams, DOCUMENT_FIELD_NAME, layerTypes } from '../../common'; import { LensDocShape } from './saved_object_migrations'; export const commonRenameOperationsForFormula = ( @@ -162,13 +163,31 @@ export const commonMakeReversePaletteAsCustom = ( return newAttributes; }; -export const commonRenameFilterReferences = (attributes: LensDocShape715) => { +export const commonRenameRecordsField = (attributes: LensDocShape810) => { + const newAttributes = cloneDeep(attributes); + Object.keys(newAttributes.state?.datasourceStates?.indexpattern?.layers || {}).forEach( + (layerId) => { + newAttributes.state.datasourceStates.indexpattern.layers[layerId].columnOrder.forEach( + (columnId) => { + const column = + newAttributes.state.datasourceStates.indexpattern.layers[layerId].columns[columnId]; + if (column && column.operationType === 'count') { + column.sourceField = DOCUMENT_FIELD_NAME; + } + } + ); + } + ); + return newAttributes; +}; + +export const commonRenameFilterReferences = (attributes: LensDocShape715): LensDocShape810 => { const newAttributes = cloneDeep(attributes); for (const filter of newAttributes.state.filters) { filter.meta.index = filter.meta.indexRefName; delete filter.meta.indexRefName; } - return newAttributes; + return newAttributes as LensDocShape810; }; const getApplyFilterMigrationToLens = (filterMigration: MigrateFunction) => { diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index 9a6407ae30552..a78cfa5e72237 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -12,7 +12,13 @@ import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc, } from 'src/core/server'; -import { LensDocShape715, VisState716, VisStatePost715, VisStatePre715 } from './types'; +import { + LensDocShape715, + LensDocShape810, + VisState716, + VisStatePost715, + VisStatePre715, +} from './types'; import { CustomPaletteParams, layerTypes } from '../../common'; import { PaletteOutput } from 'src/plugins/charts/common'; import { Filter } from '@kbn/es-query'; @@ -1513,6 +1519,78 @@ describe('Lens migrations', () => { }); }); + describe('8.1.0 rename records field', () => { + const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const example = { + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '1', + title: 'MyRenamedOps', + description: '', + visualizationType: null, + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'logstash-*', + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: 'Anzahl der Aufnahmen', + dataType: 'number', + operationType: 'count', + sourceField: 'Aufnahmen', + isBucketed: false, + scale: 'ratio', + }, + '5': { + label: 'Sum of bytes', + dataType: 'numver', + operationType: 'sum', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + }, + columnOrder: ['3', '4', '5'], + }, + }, + }, + }, + visualization: {}, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }, + } as unknown as SavedObjectUnsanitizedDoc; + + it('should change field for count operations but not for others, not changing the vis', () => { + const result = migrations['8.1.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + + expect( + Object.values( + result.attributes.state.datasourceStates.indexpattern.layers['2'].columns + ).map((column) => column.sourceField) + ).toEqual(['@timestamp', '___records___', 'bytes']); + expect(example.attributes.state.visualization).toEqual(result.attributes.state.visualization); + }); + }); + test('should properly apply a filter migration within a lens visualization', () => { const migrationVersion = 'some-version'; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index fc1718c1df2fa..d3efb4df44575 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { cloneDeep, mergeWith } from 'lodash'; +import { cloneDeep, flow, mergeWith } from 'lodash'; import { fromExpression, toExpression, Ast, AstFunction } from '@kbn/interpreter'; import { SavedObjectMigrationMap, @@ -27,6 +27,7 @@ import { VisStatePost715, VisStatePre715, VisState716, + LensDocShape810, } from './types'; import { commonRenameOperationsForFormula, @@ -35,6 +36,7 @@ import { commonMakeReversePaletteAsCustom, commonRenameFilterReferences, getLensFilterMigrations, + commonRenameRecordsField, } from './common_migrations'; interface LensDocShapePre710 { @@ -444,14 +446,16 @@ const moveDefaultReversedPaletteToCustom: SavedObjectMigrationFn< return { ...newDoc, attributes: commonMakeReversePaletteAsCustom(newDoc.attributes) }; }; -const renameFilterReferences: SavedObjectMigrationFn< - LensDocShape715, - LensDocShape715 -> = (doc) => { +const renameFilterReferences: SavedObjectMigrationFn = (doc) => { const newDoc = cloneDeep(doc); return { ...newDoc, attributes: commonRenameFilterReferences(newDoc.attributes) }; }; +const renameRecordsField: SavedObjectMigrationFn = (doc) => { + const newDoc = cloneDeep(doc); + return { ...newDoc, attributes: commonRenameRecordsField(newDoc.attributes) }; +}; + const lensMigrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -465,7 +469,7 @@ const lensMigrations: SavedObjectMigrationMap = { '7.14.0': removeTimezoneDateHistogramParam, '7.15.0': addLayerTypeToVisualization, '7.16.0': moveDefaultReversedPaletteToCustom, - '8.1.0': renameFilterReferences, + '8.1.0': flow(renameFilterReferences, renameRecordsField), }; export const mergeSavedObjectMigrationMaps = ( From 940c4e28330a3005c35ea615a70adcde9e9fb02b Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Mon, 31 Jan 2022 08:07:04 -0600 Subject: [PATCH 17/65] [cloud] Create IFRAME chat component; add to Unified Integrations (#123772) * [cloud] Create IFRAME chat component; add to Unified Integrations * Provide chat user data from endpoint * Apply suggestions from code review Co-authored-by: Luke Elmers * Addressing feedback * Addressing feedback * Fixing package.json * Addressing feedback * Wrap chat config in an observable * Add tests * Add integration tests, docs Co-authored-by: Sergei Poluektov Co-authored-by: Luke Elmers Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../steps/storybooks/build_and_upload.js | 1 + api_docs/cloud.devdocs.json | 123 +++++++++++++++--- package.json | 1 + src/dev/storybook/aliases.ts | 1 + .../lib/create_jwt_assertion.test.ts | 2 +- .../lib/create_jwt_assertion.ts | 2 +- x-pack/plugins/cloud/.storybook/decorator.tsx | 35 +++++ x-pack/plugins/cloud/.storybook/index.ts | 8 ++ x-pack/plugins/cloud/.storybook/main.ts | 10 ++ x-pack/plugins/cloud/.storybook/manager.ts | 20 +++ x-pack/plugins/cloud/.storybook/preview.ts | 11 ++ x-pack/plugins/cloud/common/constants.ts | 1 + x-pack/plugins/cloud/common/types.ts | 12 ++ .../public/components/chat/chat.stories.tsx | 20 +++ .../cloud/public/components/chat/chat.tsx | 111 ++++++++++++++++ .../components/chat/get_chat_context.test.ts | 65 +++++++++ .../components/chat/get_chat_context.ts | 36 +++++ .../cloud/public/components/chat/index.ts | 12 ++ .../plugins/cloud/public/components/index.tsx | 26 ++++ x-pack/plugins/cloud/public/index.ts | 5 +- x-pack/plugins/cloud/public/mocks.ts | 22 ---- x-pack/plugins/cloud/public/mocks.tsx | 49 +++++++ x-pack/plugins/cloud/public/plugin.test.ts | 111 ++++++++++++++++ .../cloud/public/{plugin.ts => plugin.tsx} | 87 ++++++++++++- .../plugins/cloud/public/services/index.tsx | 46 +++++++ x-pack/plugins/cloud/server/config.ts | 24 ++-- x-pack/plugins/cloud/server/plugin.ts | 16 ++- .../plugins/cloud/server/routes/chat.test.ts | 109 ++++++++++++++++ x-pack/plugins/cloud/server/routes/chat.ts | 64 +++++++++ .../cloud/server/util/generate_jwt.test.ts | 23 ++++ .../plugins/cloud/server/util/generate_jwt.ts | 24 ++++ x-pack/plugins/cloud/tsconfig.json | 2 + .../fleet/.storybook/context/index.tsx | 5 +- x-pack/plugins/fleet/kibana.json | 2 +- .../public/applications/integrations/app.tsx | 29 +++-- .../public/mock/fleet_start_services.tsx | 11 +- .../fleet/public/mock/plugin_dependencies.ts | 10 +- x-pack/plugins/fleet/public/plugin.ts | 20 ++- x-pack/test/cloud_integration/config.ts | 18 ++- x-pack/test/cloud_integration/tests/chat.ts | 30 +++++ 40 files changed, 1125 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugins/cloud/.storybook/decorator.tsx create mode 100644 x-pack/plugins/cloud/.storybook/index.ts create mode 100644 x-pack/plugins/cloud/.storybook/main.ts create mode 100644 x-pack/plugins/cloud/.storybook/manager.ts create mode 100644 x-pack/plugins/cloud/.storybook/preview.ts create mode 100644 x-pack/plugins/cloud/common/types.ts create mode 100644 x-pack/plugins/cloud/public/components/chat/chat.stories.tsx create mode 100644 x-pack/plugins/cloud/public/components/chat/chat.tsx create mode 100644 x-pack/plugins/cloud/public/components/chat/get_chat_context.test.ts create mode 100644 x-pack/plugins/cloud/public/components/chat/get_chat_context.ts create mode 100644 x-pack/plugins/cloud/public/components/chat/index.ts create mode 100644 x-pack/plugins/cloud/public/components/index.tsx delete mode 100644 x-pack/plugins/cloud/public/mocks.ts create mode 100644 x-pack/plugins/cloud/public/mocks.tsx rename x-pack/plugins/cloud/public/{plugin.ts => plugin.tsx} (80%) create mode 100644 x-pack/plugins/cloud/public/services/index.tsx create mode 100644 x-pack/plugins/cloud/server/routes/chat.test.ts create mode 100644 x-pack/plugins/cloud/server/routes/chat.ts create mode 100644 x-pack/plugins/cloud/server/util/generate_jwt.test.ts create mode 100644 x-pack/plugins/cloud/server/util/generate_jwt.ts create mode 100644 x-pack/test/cloud_integration/tests/chat.ts diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.js b/.buildkite/scripts/steps/storybooks/build_and_upload.js index 328c769d81c66..0af75e72de78a 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.js +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.js @@ -15,6 +15,7 @@ const STORYBOOKS = [ 'apm', 'canvas', 'ci_composite', + 'cloud', 'codeeditor', 'custom_integrations', 'dashboard_enhanced', diff --git a/api_docs/cloud.devdocs.json b/api_docs/cloud.devdocs.json index 6c6dbf6f48003..ce0ded5fa875b 100644 --- a/api_docs/cloud.devdocs.json +++ b/api_docs/cloud.devdocs.json @@ -2,7 +2,24 @@ "id": "cloud", "client": { "classes": [], - "functions": [], + "functions": [ + { + "parentPluginId": "cloud", + "id": "def-public.Chat", + "type": "Function", + "tags": [], + "label": "Chat", + "description": [], + "signature": [ + "() => JSX.Element" + ], + "path": "x-pack/plugins/cloud/public/components/index.tsx", + "deprecated": false, + "children": [], + "returnComment": [], + "initialIsOpen": false + } + ], "interfaces": [ { "parentPluginId": "cloud", @@ -11,7 +28,7 @@ "tags": [], "label": "CloudConfigType", "description": [], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false, "children": [ { @@ -24,7 +41,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -37,7 +54,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -50,7 +67,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -63,7 +80,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -76,7 +93,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -89,7 +106,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -102,8 +119,78 @@ "signature": [ "{ enabled: boolean; org_id?: string | undefined; }" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false + }, + { + "parentPluginId": "cloud", + "id": "def-public.CloudConfigType.chat", + "type": "Object", + "tags": [], + "label": "chat", + "description": [], + "signature": [ + "{ enabled: boolean; chatURL: string; }" + ], + "path": "x-pack/plugins/cloud/public/plugin.tsx", + "deprecated": false + } + ], + "initialIsOpen": false + }, + { + "parentPluginId": "cloud", + "id": "def-public.CloudStart", + "type": "Interface", + "tags": [], + "label": "CloudStart", + "description": [], + "path": "x-pack/plugins/cloud/public/plugin.tsx", + "deprecated": false, + "children": [ + { + "parentPluginId": "cloud", + "id": "def-public.CloudStart.CloudContextProvider", + "type": "Function", + "tags": [], + "label": "CloudContextProvider", + "description": [ + "\nA React component that provides a pre-wired `React.Context` which connects components to Cloud services." + ], + "signature": [ + "React.FunctionComponent<{}>" + ], + "path": "x-pack/plugins/cloud/public/plugin.tsx", + "deprecated": false, + "returnComment": [], + "children": [ + { + "parentPluginId": "cloud", + "id": "def-public.CloudStart.CloudContextProvider.$1", + "type": "CompoundType", + "tags": [], + "label": "props", + "description": [], + "signature": [ + "P & { children?: React.ReactNode; }" + ], + "path": "node_modules/@types/react/index.d.ts", + "deprecated": false + }, + { + "parentPluginId": "cloud", + "id": "def-public.CloudStart.CloudContextProvider.$2", + "type": "Any", + "tags": [], + "label": "context", + "description": [], + "signature": [ + "any" + ], + "path": "node_modules/@types/react/index.d.ts", + "deprecated": false + } + ] } ], "initialIsOpen": false @@ -119,7 +206,7 @@ "tags": [], "label": "CloudSetup", "description": [], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false, "children": [ { @@ -132,7 +219,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -145,7 +232,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -158,7 +245,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -171,7 +258,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -184,7 +271,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -197,7 +284,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -210,7 +297,7 @@ "signature": [ "string | undefined" ], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false }, { @@ -220,7 +307,7 @@ "tags": [], "label": "isCloudEnabled", "description": [], - "path": "x-pack/plugins/cloud/public/plugin.ts", + "path": "x-pack/plugins/cloud/public/plugin.tsx", "deprecated": false } ], diff --git a/package.json b/package.json index c5c8a81a82017..3a8673b15de1e 100644 --- a/package.json +++ b/package.json @@ -278,6 +278,7 @@ "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", "json-stringify-safe": "5.0.1", + "jsonwebtoken": "^8.3.0", "jsts": "^1.6.2", "kea": "^2.4.2", "load-json-file": "^6.2.0", diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 896ed6b0bb536..d6526781e7373 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -11,6 +11,7 @@ export const storybookAliases = { apm: 'x-pack/plugins/apm/.storybook', canvas: 'x-pack/plugins/canvas/storybook', ci_composite: '.ci/.storybook', + cloud: 'x-pack/plugins/cloud/.storybook', codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', controls: 'src/plugins/controls/storybook', custom_integrations: 'src/plugins/custom_integrations/storybook', diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts index abeb14de4c43b..ed6497573a362 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts @@ -8,7 +8,7 @@ jest.mock('jsonwebtoken', () => ({ sign: jest.fn(), })); -// eslint-disable-next-line import/no-extraneous-dependencies + import jwt from 'jsonwebtoken'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts index 9f5102b336eda..79230a99a3822 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -// eslint-disable-next-line import/no-extraneous-dependencies + import jwt, { Algorithm } from 'jsonwebtoken'; import { Logger } from '../../../../../../src/core/server'; diff --git a/x-pack/plugins/cloud/.storybook/decorator.tsx b/x-pack/plugins/cloud/.storybook/decorator.tsx new file mode 100644 index 0000000000000..4489b58f75759 --- /dev/null +++ b/x-pack/plugins/cloud/.storybook/decorator.tsx @@ -0,0 +1,35 @@ +/* + * 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 { DecoratorFn } from '@storybook/react'; +import { ServicesProvider, CloudServices } from '../public/services'; + +// TODO: move to a storybook implementation of the service using parameters. +const services: CloudServices = { + chat: { + enabled: true, + chatURL: 'https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html', + user: { + id: 'user-id', + email: 'test-user@elastic.co', + // this doesn't affect chat appearance, + // but a user identity in Drift only + jwt: 'identity-jwt', + }, + }, +}; + +export const getCloudContextProvider: () => React.FC = + () => + ({ children }) => + {children}; + +export const getCloudContextDecorator: DecoratorFn = (storyFn) => { + const CloudContextProvider = getCloudContextProvider(); + return {storyFn()}; +}; diff --git a/x-pack/plugins/cloud/.storybook/index.ts b/x-pack/plugins/cloud/.storybook/index.ts new file mode 100644 index 0000000000000..321df983cb20d --- /dev/null +++ b/x-pack/plugins/cloud/.storybook/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { getCloudContextDecorator, getCloudContextProvider } from './decorator'; diff --git a/x-pack/plugins/cloud/.storybook/main.ts b/x-pack/plugins/cloud/.storybook/main.ts new file mode 100644 index 0000000000000..bf63e08d64c32 --- /dev/null +++ b/x-pack/plugins/cloud/.storybook/main.ts @@ -0,0 +1,10 @@ +/* + * 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 { defaultConfig } from '@kbn/storybook'; + +module.exports = defaultConfig; diff --git a/x-pack/plugins/cloud/.storybook/manager.ts b/x-pack/plugins/cloud/.storybook/manager.ts new file mode 100644 index 0000000000000..54c3d31c2002f --- /dev/null +++ b/x-pack/plugins/cloud/.storybook/manager.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 { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID } from '@storybook/addon-actions'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: 'Cloud Storybook', + brandUrl: 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud', + }), + showPanel: true.valueOf, + selectedPanel: PANEL_ID, +}); diff --git a/x-pack/plugins/cloud/.storybook/preview.ts b/x-pack/plugins/cloud/.storybook/preview.ts new file mode 100644 index 0000000000000..83c512e516d5a --- /dev/null +++ b/x-pack/plugins/cloud/.storybook/preview.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { addDecorator } from '@storybook/react'; +import { getCloudContextDecorator } from './decorator'; + +addDecorator(getCloudContextDecorator); diff --git a/x-pack/plugins/cloud/common/constants.ts b/x-pack/plugins/cloud/common/constants.ts index fc37906299d14..09333e3773fe9 100644 --- a/x-pack/plugins/cloud/common/constants.ts +++ b/x-pack/plugins/cloud/common/constants.ts @@ -6,6 +6,7 @@ */ export const ELASTIC_SUPPORT_LINK = 'https://cloud.elastic.co/support'; +export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user'; /** * This is the page for managing your snapshots on Cloud. diff --git a/x-pack/plugins/cloud/common/types.ts b/x-pack/plugins/cloud/common/types.ts new file mode 100644 index 0000000000000..38ebeaf5f467c --- /dev/null +++ b/x-pack/plugins/cloud/common/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface GetChatUserDataResponseBody { + token: string; + email: string; + id: string; +} diff --git a/x-pack/plugins/cloud/public/components/chat/chat.stories.tsx b/x-pack/plugins/cloud/public/components/chat/chat.stories.tsx new file mode 100644 index 0000000000000..668017e134e75 --- /dev/null +++ b/x-pack/plugins/cloud/public/components/chat/chat.stories.tsx @@ -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 React from 'react'; + +import { Chat } from './chat'; + +export default { + title: 'Chat Widget', + description: '', + parameters: {}, +}; + +export const Component = () => { + return ; +}; diff --git a/x-pack/plugins/cloud/public/components/chat/chat.tsx b/x-pack/plugins/cloud/public/components/chat/chat.tsx new file mode 100644 index 0000000000000..99b53f553e75f --- /dev/null +++ b/x-pack/plugins/cloud/public/components/chat/chat.tsx @@ -0,0 +1,111 @@ +/* + * 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, { useEffect, useRef, useState, CSSProperties } from 'react'; +import { css } from '@emotion/react'; +import { useChat } from '../../services'; +import { getChatContext } from './get_chat_context'; + +type UseChatType = + | { enabled: false } + | { + enabled: true; + src: string; + ref: React.MutableRefObject; + style: CSSProperties; + isReady: boolean; + }; + +const MESSAGE_READY = 'driftIframeReady'; +const MESSAGE_RESIZE = 'driftIframeResize'; +const MESSAGE_SET_CONTEXT = 'driftSetContext'; + +const useChatConfig = (): UseChatType => { + const ref = useRef(null); + const chat = useChat(); + const [style, setStyle] = useState({}); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + const handleMessage = (event: MessageEvent): void => { + const { current: chatIframe } = ref; + + if ( + !chat.enabled || + !chatIframe?.contentWindow || + event.source !== chatIframe?.contentWindow + ) { + return; + } + + const context = getChatContext(); + const { data: message } = event; + const { user: userConfig } = chat; + const { id, email, jwt } = userConfig; + + switch (message.type) { + case MESSAGE_READY: { + const user = { + id, + attributes: { + email, + }, + jwt, + }; + + chatIframe.contentWindow.postMessage( + { + type: MESSAGE_SET_CONTEXT, + data: { context, user }, + }, + '*' + ); + + setIsReady(true); + + break; + } + + case MESSAGE_RESIZE: { + const styles = message.data.styles || ({} as CSSProperties); + setStyle({ ...style, ...styles }); + break; + } + + default: + break; + } + }; + + window.addEventListener('message', handleMessage); + + return () => window.removeEventListener('message', handleMessage); + }, [chat, style]); + + if (chat.enabled) { + return { enabled: true, src: chat.chatURL, ref, style, isReady }; + } + + return { enabled: false }; +}; + +export const Chat = () => { + const config = useChatConfig(); + + if (!config.enabled) { + return null; + } + + const iframeStyle = css` + position: fixed; + botton: 30px; + right: 30px; + visibility: ${config.isReady ? 'visible' : 'hidden'}; + `; + + return